use assert_cmd::Command;
use predicates::str::contains;
use crate::common::Sandbox;
fn onetaskgraph() -> Command {
Command::new(env!("CARGO_BIN_EXE_onetaskgraph"))
}
const SURFACE: &[(&[&str], &[&str])] = &[
(
&["--help"],
&[
"sources", "task", "project", "label", "search", "schema", "config",
],
),
(&["help", "sources"], &["list"]),
(
&["help", "task", "list"],
&[
"--source",
"--label",
"--not-label",
"--status",
"--project",
"--no-project",
"--search",
"--in",
"--limit",
"--page",
"--explain",
"--allow-partial",
"--json",
],
),
(&["help", "task", "show"], &["<ID>"]),
(
&["help", "task", "comment"],
&["add", "list", "edit", "delete"],
),
(
&["help", "task", "comment", "add"],
&["<ID>", "--body-file", "--author", "--json"],
),
(&["help", "task", "comment", "list"], &["<ID>", "--json"]),
(
&["help", "task", "comment", "edit"],
&["<ID>", "<COMMENT-ID>", "--body-file", "--json"],
),
(
&["help", "task", "comment", "delete"],
&["<ID>", "<COMMENT-ID>", "--json"],
),
(&["help", "task", "deps"], &["--direction", "<ID>"]),
(
&["help", "project", "list"],
&[
"--source", "--label", "--status", "--search", "--in", "--limit",
],
),
(&["help", "project", "show"], &["<ID>"]),
(&["help", "project", "deps"], &["--direction", "<ID>"]),
(&["help", "label", "list"], &["--source"]),
(&["help", "search"], &["--in", "--kind", "<TEXT>"]),
];
#[test]
fn help_names_every_verb_and_flag_the_command_surface_owes() {
for (arguments, expected) in SURFACE {
let assertion = onetaskgraph().args(*arguments).assert().success();
let rendered = String::from_utf8_lossy(&assertion.get_output().stdout).into_owned();
for name in *expected {
assert!(
rendered.contains(name),
"`onetaskgraph {}` does not mention {name}:\n{rendered}",
arguments.join(" ")
);
}
}
}
#[test]
fn a_project_list_has_no_project_filter_because_a_project_has_no_project() {
let assertion = onetaskgraph()
.args(["help", "project", "list"])
.assert()
.success();
let rendered = String::from_utf8_lossy(&assertion.get_output().stdout).into_owned();
assert!(!rendered.contains("--no-project"), "{rendered}");
}
#[test]
fn version_reports_the_crate_version_on_stdout_and_exits_zero() {
let output = onetaskgraph()
.arg("--version")
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).expect("utf-8");
assert_eq!(
stdout.trim(),
format!("onetaskgraph {}", env!("CARGO_PKG_VERSION")),
"--version must report the version this binary was built at"
);
assert!(output.stderr.is_empty(), "success stays quiet on stderr");
}
#[test]
fn help_names_the_product_and_every_verb_the_binary_answers() {
onetaskgraph()
.arg("--help")
.assert()
.success()
.stdout(contains(
"One interface over the ticketing systems your work lives in.",
))
.stdout(contains("Usage: onetaskgraph [OPTIONS] <COMMAND>"))
.stdout(contains("--version"))
.stderr(predicates::str::is_empty());
}
#[test]
fn help_for_one_verb_explains_that_verb() {
onetaskgraph()
.args(["help", "schema"])
.assert()
.success()
.stdout(contains("JSON Schema bundle"))
.stdout(contains("Usage: onetaskgraph schema"));
}
#[test]
fn schema_emits_a_bundle_covering_every_contract_root_and_plugin_config() {
let output = onetaskgraph()
.arg("schema")
.assert()
.success()
.get_output()
.clone();
assert!(output.stderr.is_empty(), "success stays quiet on stderr");
let bundle: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("schema output is valid JSON");
assert_eq!(bundle["version"], 14);
assert!(
bundle["roots"]["FailureDocument"].is_object(),
"the document a failed command writes under machine output is a root"
);
assert_eq!(
bundle["commands"],
serde_json::json!([
"schema",
"config show",
"sources list",
"task list",
"task show",
"task deps",
"task copy",
"task comment add",
"task comment list",
"task comment edit",
"task comment delete",
"task status set",
"project list",
"project show",
"project deps",
"project copy",
"document list",
"document show",
"document copy",
"label list",
"search"
])
);
let roots = bundle["roots"].as_object().expect("roots is an object");
for root in [
"Task",
"Project",
"Label",
"Capabilities",
"TaskQuery",
"ProjectQuery",
"PageOfTask",
"SourceError",
"GlobalId",
"QueryPlan",
"SourcePlan",
"Predicate",
"QualifiedTask",
"QueryResponseOfQualifiedTask",
"Document",
"Location",
"DocumentQuery",
"PageOfDocument",
"QualifiedDocument",
"QueryResponseOfQualifiedDocument",
"CopyReport",
"CopyOutcome",
"CopyAction",
"SearchHit",
"SourceListing",
"SourceListings",
"PageToken",
"EffectiveConfig",
"Setting",
"Origin",
"SecretsReport",
] {
let schema = &roots[root];
assert!(schema.is_object(), "the bundle is missing {root}");
assert!(
schema["$schema"].is_string(),
"{root} is not a self-describing schema document"
);
}
let plugins = bundle["plugin_config"]
.as_object()
.expect("plugin_config is an object");
for kind in ["github-projects", "in-memory", "linear", "local-md"] {
assert!(
plugins[kind].is_object(),
"the registry can name {kind}, so the bundle must carry its config schema"
);
}
}
#[test]
fn schema_output_is_stable_across_runs_so_a_generator_can_diff_it() {
let first = onetaskgraph()
.arg("schema")
.assert()
.success()
.get_output()
.stdout
.clone();
let second = onetaskgraph()
.arg("schema")
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(first, second, "the bundle must not vary between runs");
}
#[test]
fn help_documents_the_exit_codes_a_caller_scripts_against() {
onetaskgraph()
.arg("--help")
.assert()
.success()
.stdout(contains("Exit codes: `0` on success"))
.stdout(contains("`2` when the invocation itself was wrong"))
.stdout(contains("`4` when a query succeeded for some sources"));
}
#[cfg(target_os = "linux")]
#[test]
fn a_failed_write_to_stdout_exits_one_and_names_the_problem_on_stderr() {
use std::fs::OpenOptions;
use std::process::{Command as StdCommand, Stdio};
let full = OpenOptions::new()
.write(true)
.open("/dev/full")
.expect("/dev/full exists on Linux");
let output = StdCommand::new(env!("CARGO_BIN_EXE_onetaskgraph"))
.arg("schema")
.stdout(Stdio::from(full))
.stderr(Stdio::piped())
.output()
.expect("the binary runs");
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("onetaskgraph: could not write the schema bundle"),
"{stderr}"
);
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn an_unknown_verb_exits_non_zero_and_names_the_problem_on_stderr() {
onetaskgraph()
.arg("teleport")
.assert()
.failure()
.code(2)
.stderr(contains("unrecognized subcommand"))
.stderr(contains("--help"));
}
#[test]
fn an_unknown_flag_exits_non_zero_and_names_the_problem_on_stderr() {
onetaskgraph()
.args(["schema", "--everything"])
.assert()
.failure()
.code(2)
.stderr(contains("unexpected argument"));
}
#[test]
fn asking_for_a_project_and_for_no_project_at_once_is_refused_as_a_bad_invocation() {
onetaskgraph()
.args(["task", "list", "--project", "P-1", "--no-project"])
.assert()
.failure()
.code(2)
.stderr(contains("--project"))
.stderr(contains("cannot be used with"))
.stderr(contains("--no-project"));
}
#[test]
fn no_verb_at_all_exits_non_zero_and_points_at_help() {
onetaskgraph()
.assert()
.failure()
.code(2)
.stderr(contains("Usage: onetaskgraph"));
}
#[test]
fn a_closed_stdout_never_panics_however_the_race_lands() {
use std::io::Read as _;
use std::process::{Command as StdCommand, Stdio};
let mut child = StdCommand::new(env!("CARGO_BIN_EXE_onetaskgraph"))
.arg("schema")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary starts");
drop(child.stdout.take().expect("stdout is piped"));
let mut stderr = String::new();
child
.stderr
.take()
.expect("stderr is piped")
.read_to_string(&mut stderr)
.expect("stderr reads");
let status = child.wait().expect("the process exits");
assert!(
!stderr.contains("panicked"),
"a closed pipe must not panic: {stderr}"
);
if !status.success() {
assert!(
stderr.contains("onetaskgraph: could not write the schema bundle"),
"{stderr}"
);
}
}
fn runnable(path: &std::path::Path) {
let mut last = None;
for _ in 0..200 {
match std::process::Command::new(path).arg("--version").output() {
Ok(_) => return,
Err(error) => {
last = Some(error);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
panic!(
"the copied binary would not run after two seconds: {}",
last.expect("the loop ran at least once")
);
}
#[test]
fn help_names_the_product_however_the_executable_on_disk_is_named() {
let dir = tempfile::tempdir().expect("a temporary directory");
let renamed = dir.path().join(format!(
"onetaskgraph-under-another-name{}",
std::env::consts::EXE_SUFFIX
));
std::fs::copy(env!("CARGO_BIN_EXE_onetaskgraph"), &renamed).expect("the binary copies");
runnable(&renamed);
Command::new(&renamed)
.arg("--help")
.assert()
.success()
.stdout(contains("Usage: onetaskgraph [OPTIONS] <COMMAND>"));
Command::new(&renamed)
.args(["help", "schema"])
.assert()
.success()
.stdout(contains("Usage: onetaskgraph schema"));
}
fn readme() -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../README.md")
.canonicalize()
.expect("the README sits at the repository root");
std::fs::read_to_string(path).expect("the README is readable")
}
#[test]
fn the_readme_documents_the_command_surface_this_binary_actually_has() {
let readme = readme();
let mut missing = Vec::new();
for (arguments, expected) in SURFACE {
for name in *expected {
if name.starts_with('<') {
continue;
}
if !readme.contains(name) {
missing.push(format!("{} — {name}", arguments.join(" ")));
}
}
}
assert!(
missing.is_empty(),
"the README does not document part of the surface `--help` reports:\n {}",
missing.join("\n ")
);
let help = String::from_utf8_lossy(
&onetaskgraph()
.arg("--help")
.assert()
.success()
.get_output()
.stdout,
)
.into_owned();
let documented: Vec<&str> = ["`0`", "`1`", "`2`", "`4`"]
.into_iter()
.filter(|code| help.contains(code))
.collect();
assert_eq!(
documented.len(),
4,
"`--help` no longer names every exit code:\n{help}"
);
for code in documented {
let row = format!("| {code} |");
assert!(
readme.contains(&row),
"the README's exit-code table has no row for {code}"
);
}
for invented in ["| `3` |", "| `5` |"] {
assert!(
!readme.contains(invented),
"the README documents {invented}, which this binary never exits with"
);
}
}
const VOCABULARIES: &[(&[&str], &str, &str, bool)] = &[
(
&["help", "task", "list"],
"--status",
"StatusCategory",
true,
),
(&["help", "task", "deps"], "--direction", "Direction", true),
(&["help", "search"], "--in", "TextFields", false),
(&["help", "search"], "--kind", "SearchKind", false),
];
fn possible_values(help: &str, flag: &str) -> Vec<String> {
let after = help
.split_once(flag)
.unwrap_or_else(|| panic!("`{flag}` is not in this help text:\n{help}"))
.1;
let block = after
.split_once("Possible values:")
.unwrap_or_else(|| panic!("`{flag}` prints no possible values:\n{help}"))
.1;
let values: Vec<String> = block
.lines()
.map(str::trim)
.skip_while(|line| line.is_empty())
.take_while(|line| line.starts_with("- "))
.map(|line| {
line.trim_start_matches("- ")
.split_once(':')
.expect("clap writes `- value: description`")
.0
.to_owned()
})
.collect();
assert!(!values.is_empty(), "`{flag}` lists no values:\n{help}");
values
}
#[test]
fn the_command_line_accepts_exactly_the_vocabularies_the_contract_declares() {
let sandbox = Sandbox::new();
let bundle: serde_json::Value = serde_json::from_slice(
&sandbox
.command()
.arg("schema")
.assert()
.success()
.get_output()
.stdout,
)
.expect("the bundle is JSON");
for (arguments, flag, root, same_spelling) in VOCABULARIES {
let help = String::from_utf8_lossy(
&onetaskgraph()
.args(*arguments)
.assert()
.success()
.get_output()
.stdout,
)
.into_owned();
let accepted = possible_values(&help, flag);
let mut declared: Vec<String> = bundle["roots"][root]["oneOf"]
.as_array()
.unwrap_or_else(|| panic!("the bundle's {root} root declares no values"))
.iter()
.map(|variant| {
variant["const"]
.as_str()
.unwrap_or_else(|| panic!("{root} declares a variant with no value"))
.to_owned()
})
.collect();
assert_eq!(
accepted.len(),
declared.len(),
"`{flag}` accepts {accepted:?} while the contract's {root} declares \
{declared:?}. A variant added to one and not the other is a value a user \
can never name — add it to the mirror in crates/onetaskgraph/src/cli.rs."
);
if *same_spelling {
let mut accepted = accepted;
accepted.sort();
declared.sort();
assert_eq!(
accepted, declared,
"`{flag}` borrows {root}'s own spellings, so the two must agree value for \
value"
);
}
}
}
#[test]
fn every_value_the_help_advertises_is_one_the_command_line_actually_takes() {
let sandbox = Sandbox::new();
sandbox.project_document(&crate::fixtures::ROWS[0].document(&sandbox));
for (arguments, flag, _, _) in VOCABULARIES {
let help = String::from_utf8_lossy(
&onetaskgraph()
.args(*arguments)
.assert()
.success()
.get_output()
.stdout,
)
.into_owned();
for value in possible_values(&help, flag) {
let verb: Vec<&str> = match *flag {
"--status" => vec!["task", "list"],
"--direction" => vec!["task", "deps", "work:T-1"],
_ => vec!["search", "alpha"],
};
sandbox
.command()
.args(verb)
.args([*flag, value.as_str()])
.assert()
.success();
}
}
}