use std::fs;
use std::path::Path;
use std::process::Command;
#[cfg(not(unix))]
#[test]
fn serve_is_unix_only() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.arg("serve")
.output()
.expect("run vissue");
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("Unix-only"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
fn fixture_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault")
}
fn vissue(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", fixture_root().to_str().unwrap()])
.args(args)
.output()
.expect("run vissue")
}
fn stdout(out: &std::process::Output) -> String {
String::from_utf8(out.stdout.clone()).unwrap()
}
#[test]
fn projects_lists_the_fixture_projects() {
let out = vissue(&["projects"]);
assert!(out.status.success());
assert_eq!(stdout(&out), "atlas\nbeacon\n");
}
#[test]
fn count_and_list_accept_both_project_flags() {
assert_eq!(stdout(&vissue(&["count", "-P", "atlas"])), "4\n");
assert_eq!(stdout(&vissue(&["count", "-p", "atlas"])), "4\n");
assert_eq!(stdout(&vissue(&["count", "--project", "atlas"])), "4\n");
let listed = stdout(&vissue(&["list", "-P", "atlas"]));
assert_eq!(listed.lines().count(), 4, "{listed}");
assert!(listed.contains("atlas-1a2b"), "{listed}");
}
#[test]
fn export_emits_one_json_object_per_line() {
let out = vissue(&["export", "-P", "beacon"]);
assert!(out.status.success());
let text = stdout(&out);
assert_eq!(text.lines().count(), 2, "{text}");
for line in text.lines() {
let row: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(row["project"], "beacon");
}
}
#[test]
fn check_exits_zero_on_the_fixture() {
let out = vissue(&["check"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout(&out).contains("0 error(s), 0 warning(s)"));
}
#[test]
fn an_id_quoted_in_a_report_does_not_resolve_a_broken_parent() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("Software")).unwrap();
let own = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", dir.path().to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
own(&["create", "-p", "atlas", "The child", "-q"]);
let issues = dir.path().join("Software/atlas/issues.org");
let planted = fs::read_to_string(&issues)
.unwrap()
.replace(":CREATED:", ":PARENT: ghost-9999\n:CREATED:");
fs::write(&issues, planted).unwrap();
let broken = own(&["check"]);
assert!(
!broken.status.success() && stdout(&broken).contains("ghost-9999"),
"a parent that names nothing is an error: {}",
stdout(&broken)
);
let host = stdout(&own(&["create", "-p", "atlas", "The report", "-q"]))
.trim()
.to_string();
own(&[
"append",
&host,
"--text",
"The heading I was handed reads:\n:PROPERTIES:\n:ID: ghost-9999\n:END:\n",
]);
let after = own(&["check"]);
assert!(
!after.status.success() && stdout(&after).contains("ghost-9999"),
"quoting the id in a report silenced the check: {}",
stdout(&after)
);
}
#[test]
fn show_json_returns_an_object() {
let out = vissue(&["show", "atlas-2c3d", "--json"]);
assert!(out.status.success());
let row: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
assert_eq!(row["id"], "atlas-2c3d");
assert_eq!(row["parent"], "atlas-1a2b");
}
#[test]
fn mirror_writes_a_file_and_reports_the_path() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nested/mirror.org");
let out = vissue(&["mirror", "-P", "atlas", "--out", target.to_str().unwrap()]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(stdout(&out).starts_with("wrote "));
let text = fs::read_to_string(&target).unwrap();
assert!(
text.contains("# MIRROR: generated by `vissue mirror`"),
"{text}"
);
assert!(text.contains("* atlas"), "{text}");
assert!(!text.contains("* beacon"), "{text}");
}
#[test]
fn mirror_writes_to_stdout_on_a_dash() {
let out = vissue(&["mirror", "--out", "-", "--format", "markdown"]);
assert!(out.status.success());
let text = stdout(&out);
assert!(text.starts_with("# vissue mirror"), "{text}");
assert!(text.contains("## atlas"), "{text}");
assert!(text.contains("## beacon"), "{text}");
}
#[test]
fn an_unknown_mirror_format_fails_loudly() {
let out = vissue(&["mirror", "--out", "-", "--format", "pdf"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown format"));
}
#[test]
fn whoami_prints_the_identity_a_claim_would_record() {
let out = vissue(&["whoami"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(!text.trim().is_empty(), "no identity printed");
}
#[test]
fn tui_help_names_offline() {
let out = vissue(&["tui", "--help"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.contains("--offline"), "{text}");
assert!(text.contains("Never attach"), "{text}");
}
#[test]
fn hud_help_names_rofi_and_iced() {
let out = vissue(&["hud", "--help"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.contains("--mode"), "{text}");
assert!(text.contains("rofi"), "{text}");
assert!(text.contains("--rofi"), "{text}");
assert!(text.contains("--iced"), "{text}");
assert!(text.contains("--toggle"), "{text}");
}
#[test]
fn iced_hud_without_binary_exits_127() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", fixture_root().to_str().unwrap(), "hud"])
.env("VISSUE_HUD_BIN", "/nonexistent/vissue-hud")
.env("PATH", "/nonexistent")
.output()
.expect("run vissue hud --iced");
assert_eq!(out.status.code(), Some(127));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo install vissue-hud"), "{err}");
}
#[test]
fn hud_missing_override_bin_exits_127() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", fixture_root().to_str().unwrap(), "hud", "--iced"])
.env("VISSUE_HUD_BIN", "/nonexistent/vissue-hud")
.output()
.expect("run vissue hud --iced");
assert_eq!(out.status.code(), Some(127));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo install vissue-hud"), "{err}");
}
#[cfg(not(unix))]
#[test]
fn tui_without_offline_is_unix_only() {
let out = vissue(&["tui"]);
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("Unix-only"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[cfg(not(unix))]
#[test]
fn hud_without_offline_is_unix_only() {
let out = vissue(&["hud"]);
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("Unix-only"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn identity_reports_the_resolved_binary_root_and_prefix() {
let text = stdout(&vissue(&["identity"]));
assert!(text.contains("prefix: Software"), "{text}");
assert!(text.contains("prefix=Software"), "{text}");
assert!(text.contains("fixture_vault"), "{text}");
assert!(text.contains("binary:"), "{text}");
}
#[test]
fn the_environment_can_name_the_claiming_identity() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", fixture_root().to_str().unwrap()])
.env("VISSUE_AGENT", "grind-worker-3")
.arg("whoami")
.output()
.expect("run vissue");
assert_eq!(
String::from_utf8(out.stdout).unwrap().trim(),
"grind-worker-3"
);
}
#[test]
fn a_create_and_update_cycle_works_in_a_temporary_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", root])
.args(args)
.output()
.expect("run vissue")
};
let created = run(&["create", "-P", "demo", "--quiet", "first task"]);
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
let id = String::from_utf8(created.stdout)
.unwrap()
.trim()
.to_string();
assert!(id.starts_with("demo-"), "{id}");
let updated = run(&["update", &id, "--state", "STARTED"]);
assert!(updated.status.success());
assert!(String::from_utf8_lossy(&updated.stdout).contains("state TODO -> STARTED"));
let shown = run(&["show", &id]);
assert!(String::from_utf8_lossy(&shown.stdout).contains("State: STARTED"));
assert_eq!(
String::from_utf8_lossy(&run(&["count", "--ready"]).stdout),
"1\n"
);
}
#[test]
fn the_root_may_come_from_the_environment() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.env("VISSUE_ROOT", fixture_root())
.arg("projects")
.output()
.expect("run vissue");
assert_eq!(String::from_utf8(out.stdout).unwrap(), "atlas\nbeacon\n");
}
#[test]
fn the_shared_issue_root_environment_is_supported() {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.env_remove("VISSUE_ROOT")
.env("ISSUE_ROOT", fixture_root())
.arg("projects")
.output()
.expect("run vissue");
assert_eq!(String::from_utf8(out.stdout).unwrap(), "atlas\nbeacon\n");
}
#[test]
fn the_read_only_command_surface_dispatches_against_the_fixture() {
let cases: &[(&[&str], &str)] = &[
(&["list", "--json"], "\"id\": \"atlas-3e4f\""),
(
&["show", "atlas-1a2b"],
"Title: Parse the manifest header",
),
(&["ready", "--json"], "\"id\": \"atlas-1a2b\""),
(&["claims"], "fixture-agent"),
(&["agenda", "--days", "5000"], "beacon-5j6k"),
(&["hygiene", "--stale-days", "1"], "stale_claims=1"),
(&["waiting-on", "atlas-1a2b"], "atlas-3e4f"),
(
&["body-excerpt", "atlas-1a2b"],
"* STARTED [#A] Parse the manifest header",
),
(&["search", "parser"], "atlas-1a2b"),
(&["children", "atlas-1a2b"], "atlas-2c3d"),
(&["ancestors", "atlas-3e4f", "--depth", "3"], "1 atlas-1a2b"),
(&["impact", "atlas-1a2b", "--depth", "3"], "1 atlas-3e4f"),
(
&["related", "atlas-1a2b", "--format", "org"],
"[[id:atlas-2c3d]",
),
(&["stale", "--days", "1"], "beacon-5j6k"),
(&["count", "--state", "TODO"], "2\n"),
(
&["tree", "atlas-1a2b", "--format", "dot"],
"digraph vissue_tree",
),
(&["cycles"], "no cycles"),
(&["graph", "--project", "atlas"], "digraph vissue_graph"),
(&["backlinks", "atlas-1a2b"], "atlas-2c3d"),
(&["roadmap", "--project", "atlas"], "## atlas"),
(&["digest", "--json"], "\"issues\": 6"),
];
for (args, expected) in cases {
let out = vissue(args);
assert!(
out.status.success(),
"command {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(
text.contains(expected),
"command {args:?} did not contain {expected:?}: {text}"
);
}
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("Software")).unwrap();
let own = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", dir.path().to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
assert_eq!(stdout(&own(&["gen"])), "0\n");
assert!(
stdout(&own(&["events", "--since", "0"])).contains("generation=0 since=0 count=0"),
"{}",
stdout(&own(&["events", "--since", "0"]))
);
}
#[test]
fn a_reader_that_closes_the_pipe_is_not_a_failure() {
use std::io::Read as _;
use std::process::Stdio;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap().to_string();
for i in 0..400 {
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", &root, "q", "-p", "demo"])
.arg(format!(
"issue {i} with a title long enough that four hundred of them exceed a pipe buffer"
))
.output()
.expect("run vissue");
assert!(out.status.success());
}
let mut child = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", &root, "export"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("run vissue");
let mut stdout = child.stdout.take().unwrap();
let mut first = [0u8; 64];
stdout.read_exact(&mut first).unwrap();
drop(stdout);
let out = child.wait_with_output().expect("wait for vissue");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exited {:?}: {stderr}", out.status);
assert!(stderr.is_empty(), "{stderr}");
}
#[test]
fn show_org_writes_the_whole_heading() {
let out = vissue(&["show", "--org", "atlas-1a2b"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let text = stdout(&out);
assert!(text.starts_with("* STARTED [#A]"), "{text}");
assert!(text.contains(":ID: atlas-1a2b"), "{text}");
assert!(
text.contains(":LOGBOOK:"),
"the notes travel with it: {text}"
);
assert!(
text.contains("Scope: read the header block"),
"the body travels with it: {text}"
);
assert!(!text.contains("File:"), "{text}");
assert!(!text.contains("excerpt"), "{text}");
}
#[test]
fn show_prints_the_body_and_json_carries_it() {
let text = stdout(&vissue(&["show", "atlas-2c3d"]));
assert!(text.contains("Body:"), "{text}");
assert!(text.contains("Scope: one row per parsed record"), "{text}");
let row: serde_json::Value =
serde_json::from_str(&stdout(&vissue(&["show", "atlas-2c3d", "--json"]))).unwrap();
assert!(
row["body"]
.as_str()
.unwrap()
.contains("one row per parsed record"),
"{row}"
);
}
#[test]
fn show_org_and_json_are_not_asked_for_together() {
let out = vissue(&["show", "--org", "--json", "atlas-1a2b"]);
assert!(!out.status.success(), "{}", stdout(&out));
}
#[test]
fn append_reads_a_file_and_stdin() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("Software")).unwrap();
let mk = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let id = String::from_utf8_lossy(
&mk(&["create", "-p", "atlas", "--quiet", "Streaming exporter"]).stdout,
)
.trim()
.to_string();
let report = dir.path().join("SUMMARY.md");
std::fs::write(&report, "## What changed\n\n* took a Read\n").unwrap();
let out = mk(&["append", &id, "--file", report.to_str().unwrap()]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let shown = String::from_utf8_lossy(&mk(&["show", &id]).stdout).to_string();
assert!(shown.contains("## What changed"), "{shown}");
assert!(shown.contains("took a Read"), "{shown}");
assert!(mk(&["append", &id, "--text", "second pass"])
.status
.success());
let shown = String::from_utf8_lossy(&mk(&["show", &id]).stdout).to_string();
assert!(shown.contains("second pass"), "{shown}");
let check = mk(&["check"]);
assert!(
check.status.success(),
"{}",
String::from_utf8_lossy(&check.stdout)
);
assert!(!mk(&["append", &id]).status.success());
}
#[test]
fn keys_prints_the_catalog_and_checks_an_overlay() {
let dir = tempfile::tempdir().unwrap();
let keys = |overlay: Option<&str>, args: &[&str]| -> std::process::Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_vissue"));
cmd.args(["--root", fixture_root().to_str().unwrap(), "keys"]);
cmd.args(args);
match overlay {
Some(path) => cmd.env("VISSUE_KEYS", path),
None => cmd.env_remove("VISSUE_KEYS"),
};
cmd.output().unwrap()
};
let table = keys(None, &[]);
assert!(table.status.success());
let text = stdout(&table);
for action in ["list.down", "issue.claim", "board.help"] {
assert!(text.contains(action), "{action} missing: {text}");
}
let taken = keys(None, &["--occupancy"]);
assert!(taken.status.success());
assert!(stdout(&taken).contains('\t'), "{}", stdout(&taken));
let good = dir.path().join("good.toml");
fs::write(&good, "[board]\n\"list.down\" = \"e\"\n").unwrap();
let checked = keys(Some(good.to_str().unwrap()), &["--check"]);
assert!(
checked.status.success(),
"{}",
String::from_utf8_lossy(&checked.stderr)
);
assert!(stdout(&checked).trim() == "ok", "{}", stdout(&checked));
let listed = stdout(&keys(Some(good.to_str().unwrap()), &[]));
let row = listed
.lines()
.find(|l| l.contains("list.down"))
.unwrap_or_else(|| panic!("no list.down row: {listed}"));
assert_eq!(
row.split_whitespace().last(),
Some("e"),
"the rebound chord is not shown: {row}"
);
let bad = dir.path().join("bad.toml");
fs::write(&bad, "[board]\n\"list.down\" = \"enter\"\n").unwrap();
let refused = keys(Some(bad.to_str().unwrap()), &["--check"]);
assert!(!refused.status.success());
assert!(
String::from_utf8_lossy(&refused.stderr).contains("reserved"),
"{}",
String::from_utf8_lossy(&refused.stderr)
);
}
#[test]
fn the_iced_hud_is_given_the_root_prefix_and_flags() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let record = dir.path().join("argv");
let fake = dir.path().join("fake-hud");
let staging = fake.with_extension("staging");
fs::write(
&staging,
format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n", record.display()),
)
.unwrap();
let mut perm = fs::metadata(&staging).unwrap().permissions();
perm.set_mode(0o755);
fs::set_permissions(&staging, perm).unwrap();
fs::rename(&staging, &fake).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args([
"--root",
fixture_root().to_str().unwrap(),
"hud",
"--iced",
"--offline",
"--toggle",
])
.env("VISSUE_HUD_BIN", &fake)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let argv = fs::read_to_string(&record).unwrap();
let args: Vec<&str> = argv.lines().collect();
assert!(args.contains(&"--root"), "{args:?}");
assert!(args.contains(&"--prefix"), "{args:?}");
assert!(args.contains(&"--offline"), "{args:?}");
assert!(args.contains(&"--toggle"), "{args:?}");
assert!(
args.iter().any(|a| a.contains("fixture_vault")),
"the board was not pointed at this tracker: {args:?}"
);
}
#[test]
fn a_body_can_be_piped_in() {
use std::io::Write;
use std::process::Stdio;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("Software")).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args([
"--root",
dir.path().to_str().unwrap(),
"create",
"-p",
"atlas",
"--quiet",
"--body-file",
"-",
"Read from a pipe",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"the body came down the pipe\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let shown = Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(["--root", dir.path().to_str().unwrap(), "show", &id])
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&shown.stdout).contains("the body came down the pipe"),
"{}",
String::from_utf8_lossy(&shown.stdout)
);
}
#[test]
fn the_reference_lists_every_subcommand() {
let help = stdout(&vissue(&["--help"]));
let mut in_commands = false;
let mut commands: Vec<String> = Vec::new();
for line in help.lines() {
if line.starts_with("Commands:") {
in_commands = true;
continue;
}
if in_commands && line.starts_with("Options:") {
break;
}
if !in_commands {
continue;
}
let trimmed = line.trim_start();
if trimmed.is_empty() || line.len() - trimmed.len() > 4 {
continue;
}
if let Some(name) = trimmed.split_whitespace().next() {
if name != "help" {
commands.push(name.to_string());
}
}
}
assert!(commands.len() > 20, "no subcommands parsed: {commands:?}");
let reference = fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/orgmode/reference.org"),
)
.expect("reference");
let missing: Vec<&String> = commands
.iter()
.filter(|name| !reference.contains(&format!("={name}=")))
.collect();
assert!(
missing.is_empty(),
"not in docs/orgmode/reference.org: {missing:?}"
);
}
#[test]
fn the_change_stream_reports_and_blocks_the_way_a_poller_needs() {
use std::time::Instant;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("Software")).unwrap();
let run = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let gen_now = |run: &dyn Fn(&[&str]) -> std::process::Output| -> u64 {
stdout(&run(&["gen"])).trim().parse().expect("a generation")
};
assert_eq!(gen_now(&run), 0);
let empty = stdout(&run(&["events", "--since", "0"]));
assert!(empty.contains("count=0"), "{empty}");
let id = stdout(&run(&["create", "-p", "atlas", "--quiet", "watch me"]))
.trim()
.to_string();
let after_create = gen_now(&run);
assert!(after_create > 0, "a write did not move the generation");
let started = Instant::now();
let caught_up = run(&["wait", "--last", "0", "--timeout-ms", "5000"]);
assert!(
caught_up.status.success(),
"wait reported no change though the generation had moved"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(3),
"wait sat out the timeout on a generation that had already moved"
);
assert_eq!(
stdout(&caught_up).trim(),
after_create.to_string(),
"wait did not print the generation it woke at"
);
let quiet = run(&[
"wait",
"--last",
&after_create.to_string(),
"--timeout-ms",
"700",
]);
assert_eq!(quiet.status.code(), Some(2), "{}", stdout(&quiet));
let mut poker = Command::new("sh")
.arg("-c")
.arg(format!(
"sleep 1; {} --root {} note {id} poke >/dev/null 2>&1",
env!("CARGO_BIN_EXE_vissue"),
root.to_str().unwrap()
))
.spawn()
.unwrap();
let woken = run(&[
"wait",
"--last",
&after_create.to_string(),
"--timeout-ms",
"15000",
]);
let _ = poker.wait();
assert!(
woken.status.success(),
"wait slept through another process's write"
);
let events = stdout(&run(&["events", "--since", "0"]));
assert!(events.contains("issues_write"), "{events}");
assert!(events.contains("atlas"), "{events}");
let json = events
.split("---json---")
.nth(1)
.unwrap_or_else(|| panic!("no json block: {events}"));
let parsed: serde_json::Value = serde_json::from_str(json.trim()).expect("events JSON");
let recorded = parsed["events"].as_array().expect("an events array");
assert!(recorded.len() >= 2, "{parsed}");
assert_eq!(recorded[0]["kind"], "issues_write", "{parsed}");
assert_eq!(recorded[0]["project"], "atlas", "{parsed}");
let tail = stdout(&run(&["events", "--since", &gen_now(&run).to_string()]));
assert!(tail.contains("count=0"), "{tail}");
}
#[test]
fn mirror_check_reports_freshness_in_its_exit_code() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("Software")).unwrap();
let run = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let id = stdout(&run(&["create", "-p", "atlas", "--quiet", "first"]))
.trim()
.to_string();
run(&["create", "-p", "beacon", "--quiet", "second"]);
let mirror = root.join("mirror.org");
let written = run(&["mirror", "--out", mirror.to_str().unwrap()]);
assert!(written.status.success(), "{}", stdout(&written));
let fresh = run(&["mirror", "--check", mirror.to_str().unwrap()]);
assert!(fresh.status.success(), "a new mirror read as stale");
assert!(stdout(&fresh).contains("fresh:"), "{}", stdout(&fresh));
run(&["note", &id, "moved on"]);
let stale = run(&["mirror", "--check", mirror.to_str().unwrap()]);
assert_eq!(stale.status.code(), Some(1), "{}", stdout(&stale));
let report = stdout(&stale);
assert!(report.contains("stale:"), "{report}");
assert!(report.contains("moved: atlas"), "{report}");
run(&["mirror", "--out", mirror.to_str().unwrap()]);
assert!(
run(&["mirror", "--check", mirror.to_str().unwrap()])
.status
.success(),
"a regenerated mirror still read as stale"
);
let prose = root.join("notes.org");
fs::write(&prose, "just prose\n").unwrap();
let unstamped = run(&["mirror", "--check", prose.to_str().unwrap()]);
assert_eq!(unstamped.status.code(), Some(1), "{}", stdout(&unstamped));
assert!(
stdout(&unstamped).contains("no SYNC stamp"),
"{}",
stdout(&unstamped)
);
let absent = run(&[
"mirror",
"--check",
root.join("absent.org").to_str().unwrap(),
]);
assert_eq!(absent.status.code(), Some(1));
let atlas_only = root.join("atlas.org");
run(&[
"mirror",
"-p",
"atlas",
"--out",
atlas_only.to_str().unwrap(),
]);
let beacon_id = stdout(&run(&["list", "-P", "beacon"]))
.split_whitespace()
.next()
.expect("a beacon issue")
.to_string();
let elsewhere = run(&["note", &beacon_id, "elsewhere"]);
assert!(
elsewhere.status.success(),
"the note that makes this test mean anything failed: {}",
String::from_utf8_lossy(&elsewhere.stderr)
);
assert_eq!(
run(&["mirror", "--check", mirror.to_str().unwrap()])
.status
.code(),
Some(1),
"the beacon note did not move the tracker at all"
);
assert!(
run(&["mirror", "--check", atlas_only.to_str().unwrap()])
.status
.success(),
"a change in another project staled an atlas-only mirror"
);
}
#[test]
fn a_project_comes_into_being_when_something_is_filed_there() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("Software")).unwrap();
let run = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let projects = |run: &dyn Fn(&[&str]) -> std::process::Output| -> Vec<String> {
stdout(&run(&["projects"]))
.lines()
.map(str::to_string)
.collect()
};
let id = stdout(&run(&["create", "-p", "atlas", "--quiet", "one"]))
.trim()
.to_string();
assert_eq!(projects(&run), ["atlas"]);
let folded = run(&["create", "-p", "Atlas", "--quiet", "two"]);
assert!(folded.status.success(), "{}", stdout(&folded));
assert!(
stdout(&folded).trim().starts_with("atlas-"),
"{}",
stdout(&folded)
);
assert_eq!(
projects(&run),
["atlas"],
"a case variant split the project"
);
let elsewhere = run(&["create", "-p", "brandnew", "--quiet", "three"]);
assert!(elsewhere.status.success());
assert!(projects(&run).contains(&"brandnew".to_string()));
let moved = run(&["refile", &id, "--to", "somewhere-else"]);
assert!(moved.status.success(), "{}", stdout(&moved));
assert!(projects(&run).contains(&"somewhere-else".to_string()));
let detail: serde_json::Value =
serde_json::from_str(&stdout(&run(&["show", &id, "--json"]))).unwrap();
assert_eq!(detail["id"], id.as_str());
assert_eq!(detail["project"], "somewhere-else");
for name in ["Beacon", "beacon"] {
let path = root.join("Software").join(name);
fs::create_dir_all(&path).unwrap();
fs::write(path.join("issues.org"), "#+TITLE: x\n").unwrap();
}
let ambiguous = run(&["create", "-p", "BEACON", "--quiet", "which one"]);
assert!(!ambiguous.status.success(), "{}", stdout(&ambiguous));
let err = String::from_utf8_lossy(&ambiguous.stderr);
assert!(err.contains("ambiguous"), "{err}");
assert!(err.contains("Beacon") && err.contains("beacon"), "{err}");
}
#[test]
fn a_blocker_ring_is_refused_and_a_planted_one_does_not_spin() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("Software")).unwrap();
let run = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let mk = |title: &str| {
stdout(&run(&["create", "-p", "atlas", "--quiet", title]))
.trim()
.to_string()
};
let (a, b, c) = (mk("A"), mk("B"), mk("C"));
assert!(run(&["update", &b, "--block", &a]).status.success());
assert!(run(&["update", &c, "--block", &b]).status.success());
assert!(stdout(&run(&["cycles"])).contains("no cycles"));
let ready = stdout(&run(&["ready"]));
assert_eq!(ready.lines().count(), 1, "{ready}");
assert!(
ready.contains(&a),
"the root of the chain is the ready one: {ready}"
);
let ring = run(&["update", &a, "--block", &c]);
assert_eq!(ring.status.code(), Some(1), "{}", stdout(&ring));
let err = String::from_utf8_lossy(&ring.stderr);
assert!(err.contains("blocker cycle"), "{err}");
let own = run(&["update", &a, "--block", &a]);
assert_eq!(own.status.code(), Some(1), "{}", stdout(&own));
let err = String::from_utf8_lossy(&own.stderr);
assert!(err.contains("cannot block itself"), "{err}");
assert!(stdout(&run(&["cycles"])).contains("no cycles"));
assert!(run(&["check"]).status.success());
let path = root.join("Software/atlas/issues.org");
let text = fs::read_to_string(&path).unwrap();
fs::write(
&path,
text.replace(
&format!(":ID: {a}\n"),
&format!(":ID: {a}\n:BLOCKED_BY: {c}\n"),
),
)
.unwrap();
let found = stdout(&run(&["cycles"]));
for id in [&a, &b, &c] {
assert!(found.contains(id.as_str()), "{found}");
}
let checked = run(&["check"]);
assert_eq!(checked.status.code(), Some(1), "{}", stdout(&checked));
let ready = run(&["ready"]);
assert!(
ready.status.success(),
"ready failed outright: {}",
String::from_utf8_lossy(&ready.stderr)
);
assert!(
stdout(&ready).trim().is_empty(),
"a ring offered work anyway: {}",
stdout(&ready)
);
}
#[test]
fn concurrent_writers_all_land() {
use std::process::Stdio;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("Software")).unwrap();
const WRITERS: usize = 12;
let mut kids = Vec::with_capacity(WRITERS);
for i in 0..WRITERS {
kids.push(
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args([
"--root",
root.to_str().unwrap(),
"create",
"-p",
"atlas",
"--quiet",
&format!("writer {i}"),
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
}
for (i, kid) in kids.into_iter().enumerate() {
let out = kid.wait_with_output().unwrap();
assert!(
out.status.success(),
"writer {i} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let run = |args: &[&str]| -> std::process::Output {
let mut argv = vec!["--root", root.to_str().unwrap()];
argv.extend_from_slice(args);
Command::new(env!("CARGO_BIN_EXE_vissue"))
.args(argv)
.output()
.unwrap()
};
let count: usize = stdout(&run(&["count"])).trim().parse().expect("a count");
assert_eq!(count, WRITERS, "a write was lost: {count} of {WRITERS}");
let listed = stdout(&run(&["list"]));
for i in 0..WRITERS {
assert!(
listed.contains(&format!("writer {i}")),
"writer {i} is missing: {listed}"
);
}
assert!(
run(&["check"]).status.success(),
"{}",
stdout(&run(&["check"]))
);
}