use super::{package_name, run, run_to, split_build_args};
use nichlink::identity::NodeId;
use nichlink::plugin::graft_document::GraftPlanDocument;
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
fn run_capture(args: &[&str]) -> (Result<(), String>, String) {
let mut buffer = Vec::new();
let result = run_to(args.iter().map(|value| (*value).to_owned()), &mut buffer);
(result, String::from_utf8(buffer).expect("utf-8 output"))
}
fn fixture_host(label: &str, entry: &str, faces: &[(&str, &str)]) -> PathBuf {
let root = temporary_root(label);
fs::create_dir_all(root.join("src")).expect("fixture src");
fs::write(
root.join("Cargo.toml"),
format!("[package]\nname = \"{label}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.expect("manifest");
fs::write(root.join("src/lib.rs"), entry).expect("entry");
for (relative, source) in faces {
let path = root.join("src").join(relative);
fs::create_dir_all(path.parent().expect("face parent")).expect("face directory");
fs::write(&path, source).expect("face source");
}
root
}
fn control_tree() -> Vec<(&'static str, &'static str)> {
vec![
(
"control/control.rs",
"crate::root_object! {\n kind: Control,\n needs_registry: true,\n parent: crate::root_node_id(env!(\"CARGO_PKG_NAME\")),\n}\n",
),
(
"control/object/object.rs",
"crate::control_object! {\n kind: Object,\n parent: crate::control::NODE_ID,\n needs_registry: true,\n}\n",
),
(
"control/object/button/button.rs",
"crate::object_object! {\n kind: Button,\n parent: crate::control::object::NODE_ID,\n}\n",
),
]
}
const DECLARED_BUTTON: &str = "// host entry\nnichlink_run_method::static_graft_plan!(\n FRAMEWORK,\n cut \"root/control/object/button\" graft \"button_fast\",\n);\n";
#[test]
fn check_json_emits_one_document_and_still_fails() {
let root = fixture_host(
"cli-check-json",
"// host\n",
&[
(
"one/one.rs",
"crate::root_object! {\n kind: One,\n stable_name: \"dup\",\n}\n",
),
(
"two/two.rs",
"crate::root_object! {\n kind: Two,\n stable_name: \"dup\",\n}\n",
),
],
);
let path = root.display().to_string();
let (result, stdout) = run_capture(&["nichlink", "check", "--json", &path]);
assert!(result.is_err(), "a duplicate stable_name must fail");
assert_eq!(stdout.lines().count(), 1, "exactly one document: {stdout}");
let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
assert_eq!(document["schema"], "nichlink.build-diagnostics/1");
assert!(document["count"].as_u64().expect("count") >= 1);
assert!(
document["diagnostics"]
.as_array()
.expect("diagnostics")
.iter()
.any(|diagnostic| diagnostic["message"]
.as_str()
.unwrap_or_default()
.contains("duplicate stable_name")),
"{document}"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn check_without_json_keeps_the_human_line() {
let root = fixture_host("cli-check-human", "// host\n", &[]);
let path = root.display().to_string();
let (result, stdout) = run_capture(&["nichlink", "check", &path]);
assert!(result.is_ok(), "{result:?}");
assert_eq!(stdout, "nichlink check: ok (cli-check-human)\n");
assert!(
run(["nichlink".to_owned(), "check".to_owned(), path]).is_ok(),
"the public `run` entry point accepts check"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn explain_json_reports_identity_scope_and_declared_grafts() {
let root = fixture_host("cli-explain", DECLARED_BUTTON, &control_tree());
let path = root.display().to_string();
let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
assert!(checked.is_ok(), "{checked:?} {check_stdout}");
let (result, stdout) = run_capture(&[
"nichlink",
"explain",
"--json",
"--path",
&path,
"root/control/object/button",
]);
assert!(result.is_ok(), "{result:?} {stdout}");
let document: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(document["resolved"], true);
assert_eq!(document["node"]["kind"], "Button");
assert_eq!(
document["node"]["source"],
"control/object/button/button.rs"
);
assert_eq!(document["node"]["registry_name"], "button");
assert_eq!(document["node"]["parent"]["path"], "root/control/object");
assert_eq!(document["scope"]["known"], true);
assert_eq!(document["pruning"]["known"], true);
assert_eq!(document["declared_grafts"]["count"], 1);
assert_eq!(
document["declared_grafts"]["cuts"][0]["graft"],
"button_fast"
);
let id = document["node"]["id"].as_str().expect("node id").to_owned();
let (result, stdout) = run_capture(&["nichlink", "explain", "--json", "--path", &path, &id]);
assert!(result.is_ok(), "{result:?} {stdout}");
let by_id: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(by_id["node"]["path"], "root/control/object/button");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn explain_reports_an_unknown_scope_when_the_build_output_is_stale() {
let root = fixture_host("cli-explain-stale", DECLARED_BUTTON, &control_tree());
let path = root.display().to_string();
let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
assert!(checked.is_ok(), "{checked:?} {check_stdout}");
let query = [
"nichlink",
"explain",
"--json",
"--path",
&path,
"root/control/object/button",
];
let (result, stdout) = run_capture(&query);
assert!(result.is_ok(), "{result:?} {stdout}");
let fresh: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(fresh["scope"]["known"], true, "{fresh}");
assert_eq!(fresh["pruning"]["known"], true, "{fresh}");
let button = root.join("src/control/object/button/button.rs");
let text = fs::read_to_string(&button).expect("button source");
fs::write(&button, format!("// edited\n{text}")).expect("edited button");
let (result, stdout) = run_capture(&query);
assert!(result.is_ok(), "{result:?} {stdout}");
let stale: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(
stale["scope"]["known"], false,
"a previous build must not answer for these sources: {stale}"
);
assert_eq!(stale["pruning"]["known"], false, "{stale}");
assert_eq!(stale["resolved"], true, "the node itself is resolved live");
let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
assert!(checked.is_ok(), "{checked:?} {check_stdout}");
let (_, stdout) = run_capture(&query);
let current: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(current["scope"]["known"], true, "{current}");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn explain_reports_why_a_node_cannot_be_resolved() {
let root = fixture_host(
"cli-explain-missing",
"// host\n",
&[("one/one.rs", "crate::root_object! {\n kind: One,\n}\n")],
);
let path = root.display().to_string();
let (result, stdout) = run_capture(&[
"nichlink",
"explain",
"--json",
"--path",
&path,
"root/nope",
]);
assert!(result.is_err());
let document: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(document["resolved"], false);
assert!(
document["reason"]
.as_str()
.unwrap_or_default()
.contains("root/nope"),
"{document}"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn grafts_json_lists_plans_and_declared_state() {
let root = fixture_host("cli-grafts", DECLARED_BUTTON, &control_tree());
let plan_dir = root.join(".nichlink/external-grafts/button_fast");
fs::create_dir_all(&plan_dir).expect("plan directory");
let target =
NodeId::from_namespaced_path("cli-grafts", "control/object/button/button.rs", "Button");
let document =
GraftPlanDocument::new(target, "root/control/object/button", "button_fast", false);
fs::write(plan_dir.join("graft.plan"), document.render()).expect("plan file");
let broken = root.join(".nichlink/external-grafts/broken_graft");
fs::create_dir_all(&broken).expect("broken directory");
fs::write(broken.join("graft.plan"), "version=9\n").expect("broken plan");
let path = root.display().to_string();
let (result, stdout) = run_capture(&["nichlink", "grafts", "--json", &path]);
assert!(result.is_ok(), "{result:?} {stdout}");
let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(report["schema"], "nichlink.grafts/1");
let plans = report["plans"].as_array().expect("plans");
assert_eq!(plans.len(), 2);
let button = plans
.iter()
.find(|plan| plan["selector"] == "button_fast")
.expect("button plan");
assert_eq!(button["target_path"], "root/control/object/button");
assert_eq!(button["graft"], "button_fast");
assert_eq!(button["full"], false);
assert_eq!(button["declared"], true);
assert_eq!(button["declared_by"]["graft"], "button_fast");
let broken_row = plans
.iter()
.find(|plan| plan["selector"] == "broken_graft")
.expect("broken plan");
assert!(broken_row["error"].as_str().is_some(), "{broken_row}");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn grafts_reports_an_unreadable_plans_directory() {
let root = fixture_host("cli-grafts-unreadable", DECLARED_BUTTON, &control_tree());
fs::create_dir_all(root.join(".nichlink")).expect("nichlink directory");
fs::write(root.join(".nichlink/external-grafts"), "not a directory").expect("blocking file");
let path = root.display().to_string();
let (result, stdout) = run_capture(&["nichlink", "grafts", "--json", &path]);
let error = result.expect_err("an unreadable plans directory must fail");
assert!(error.contains("external-grafts"), "{error}");
let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(report["plans"].as_array().map(Vec::len), Some(0));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn grafts_reports_a_host_whose_sources_cannot_be_read() {
let root = temporary_root("cli-grafts-no-src");
fs::create_dir_all(root.join("library")).expect("library directory");
fs::write(root.join("library/lib.rs"), "pub fn placeholder() {}\n").expect("library target");
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"no-src\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\npath = \"library/lib.rs\"\n",
)
.expect("manifest");
let path = root.display().to_string();
let (result, _stdout) = run_capture(&["nichlink", "grafts", &path]);
let error = result.expect_err("a host without sources cannot be answered about");
assert!(error.contains("source tree"), "{error}");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn explain_overlay_renders_the_static_projection() {
let root = fixture_host("cli-overlay", DECLARED_BUTTON, &control_tree());
let plan_dir = root.join(".nichlink/external-grafts/button_fast");
fs::create_dir_all(&plan_dir).expect("plan directory");
let target =
NodeId::from_namespaced_path("cli-overlay", "control/object/button/button.rs", "Button");
let document =
GraftPlanDocument::new(target, "root/control/object/button", "button_fast", false);
fs::write(plan_dir.join("graft.plan"), document.render()).expect("plan file");
let path = root.display().to_string();
let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
assert!(checked.is_ok(), "{checked:?} {check_stdout}");
let (result, stdout) = run_capture(&[
"nichlink",
"explain",
"--overlay",
"--json",
"--path",
&path,
]);
assert!(result.is_ok(), "{result:?} {stdout}");
let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
assert_eq!(report["schema"], "nichlink.explain-overlay/1");
assert_eq!(report["kind"], "static-projection");
let button = report["slots"]
.as_array()
.expect("slots")
.iter()
.find(|slot| slot["path"] == "root/control/object/button")
.expect("declared slot stays in the kept list");
assert_eq!(button["replacement"]["graft"], "button_fast");
assert!(
report["slots"]
.as_array()
.expect("slots")
.iter()
.any(|slot| slot["path"] == "root/control/object" && slot["kept"] == true),
"a needed parent stays in the kept list: {report}"
);
assert_eq!(
report["plans"]
.as_array()
.expect("plans")
.iter()
.filter(|plan| plan["selector"] == "button_fast")
.count(),
1
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn help_and_missing_command_succeed() {
assert!(run(["nichlink".to_owned(), "--help".to_owned()]).is_ok());
assert!(run(["nichlink".to_owned()]).is_ok());
}
#[test]
fn unknown_command_is_an_error() {
let result = run(["nichlink".to_owned(), "bogus".to_owned()]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unknown command 'bogus'"));
}
#[test]
fn new_requires_a_package_name() {
let result = run(["nichlink".to_owned(), "new".to_owned()]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("package name"));
}
#[test]
fn build_args_split_leading_path_from_cargo_options() {
let owned = |items: &[&str]| {
items
.iter()
.map(|item| item.to_string())
.collect::<Vec<_>>()
};
let (path, rest) = split_build_args(&owned(&["app", "--release"]));
assert_eq!(path.as_deref(), Some("app"));
assert_eq!(rest, owned(&["--release"]));
let (path, rest) = split_build_args(&owned(&["--release"]));
assert_eq!(path, None);
assert_eq!(rest, owned(&["--release"]));
let (path, rest) = split_build_args(&[]);
assert_eq!(path, None);
assert!(rest.is_empty());
}
#[test]
fn snippets_refuses_an_unknown_editor_and_a_stray_path() {
let unknown = run([
"nichlink".to_owned(),
"snippets".to_owned(),
"--editor".to_owned(),
"emacs".to_owned(),
])
.expect_err("unknown editor");
assert!(unknown.contains("unknown editor 'emacs'"), "{unknown}");
assert!(unknown.contains("vscode, nvim, blink"), "{unknown}");
let stray = run([
"nichlink".to_owned(),
"snippets".to_owned(),
"--editor".to_owned(),
"nvim".to_owned(),
"/tmp/somewhere".to_owned(),
])
.expect_err("stray path");
assert!(stray.contains("editor config"), "{stray}");
}
#[test]
fn snippets_injects_the_editor_file() {
let root = std::env::temp_dir().join(format!(
"nichlink-cli-snippets-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&root).expect("temporary directory");
run([
"nichlink".to_owned(),
"snippets".to_owned(),
root.display().to_string(),
])
.expect("snippets command");
let path = root.join(nichlink_build_method::scaffold::SNIPPET_FILE);
let text = std::fs::read_to_string(&path).expect("editor file");
let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
let snippets = parsed.as_object().expect("an object of snippets");
assert_eq!(
snippets.len(),
nichlink::registry_core::declaration::FACE_FIELD_ORDER.len()
);
let kind = snippets.get("kind: ").expect("the kind snippet");
assert_eq!(kind["prefix"][0], "kind: ");
assert_eq!(kind["body"][0], "kind: $1,");
assert_eq!(kind["scope"], "rust");
assert!(
run([
"nichlink".to_owned(),
"snippets".to_owned(),
"--bogus".to_owned()
])
.is_err()
);
assert!(
run([
"nichlink".to_owned(),
"snippets".to_owned(),
"a".to_owned(),
"b".to_owned()
])
.is_err()
);
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn package_name_comes_from_cargo_not_from_a_manifest_scan() {
let root = temporary_root("package-name");
let fixtures = [
(
"single-quoted",
"[package]\nname = 'single-quoted'\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"other-bin\"\npath = \"src/main.rs\"\n",
"single-quoted",
),
(
"tight",
"[package]\nname=\"tight\"\nversion = \"0.1.0\"\n\n[lib]\nname = \"different_lib\"\npath = \"src/lib.rs\"\n",
"tight",
),
];
for (directory, manifest, expected) in fixtures {
let manifest_dir = root.join(directory);
std::fs::create_dir_all(manifest_dir.join("src")).expect("source directory");
std::fs::write(manifest_dir.join("Cargo.toml"), manifest).expect("manifest");
std::fs::write(manifest_dir.join("src/main.rs"), "").expect("binary source");
std::fs::write(manifest_dir.join("src/lib.rs"), "").expect("library source");
assert_eq!(
package_name(&manifest_dir).expect("cargo answers"),
expected,
"{directory}"
);
}
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn a_manifest_without_a_package_is_an_error() {
let root = temporary_root("virtual-manifest");
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = []\nresolver = \"2\"\n",
)
.expect("manifest");
let error = package_name(&root).expect_err("a virtual manifest names no package");
assert!(error.contains("not a package"), "{error}");
std::fs::remove_dir_all(root).expect("cleanup");
}
fn temporary_root(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"nichlink-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&root).expect("temporary directory");
root
}