use super::{render_diagnostic_json, render_error_json};
use crate::ir::IrGenError;
use crate::localization::{self, keys};
use crate::manifest;
use crate::runner::RunnerError;
use anyhow::{Context, Result, ensure};
use camino::Utf8PathBuf;
use insta::assert_snapshot;
use proptest::prelude::*;
use rstest::rstest;
use serde_json::{Map, Value};
use std::path::PathBuf;
use test_support::{EnLocalizer, en_localizer};
use crate::snapshot_test_support::diagnostic_json_snapshot_settings as snapshot_settings;
fn parse_json_value(document: &str) -> Result<Value> {
serde_json::from_str(document).context("parse diagnostics JSON")
}
#[rstest]
fn snapshot_filter_preserves_versions_outside_the_generator_block() {
let rendered = concat!(
"{\n",
" \"generator\": {\n",
" \"name\": \"netsuke\",\n",
" \"version\": \"9.9.9\"\n",
" },\n",
" \"tool\": {\n",
" \"name\": \"netsuke\",\n",
" \"version\": \"1.2.3\"\n",
" }\n",
"}"
);
snapshot_settings().bind(|| {
assert_snapshot!(rendered, @r#"
{
"generator": {
"name": "netsuke",
"version": "[version]"
},
"tool": {
"name": "netsuke",
"version": "1.2.3"
}
}
"#);
});
}
fn first_diagnostic(value: &Value) -> Result<&Map<String, Value>> {
value
.get("diagnostics")
.and_then(Value::as_array)
.and_then(|diagnostics| diagnostics.first())
.and_then(Value::as_object)
.context("diagnostic entry should be an object")
}
fn manifest_not_found_error() -> RunnerError {
RunnerError::ManifestNotFound {
manifest_name: String::from("Netsukefile"),
directory: String::from("the current directory"),
path: PathBuf::from("/workspace/Netsukefile"),
message: localization::message(keys::RUNNER_MANIFEST_NOT_FOUND)
.with_arg("manifest_name", "Netsukefile")
.with_arg("directory", "the current directory"),
help: localization::message(keys::RUNNER_MANIFEST_NOT_FOUND_HELP),
}
}
fn circular_dependency_error_for(cycle_nodes: Vec<&str>) -> IrGenError {
let cycle: Vec<Utf8PathBuf> = cycle_nodes.into_iter().map(Utf8PathBuf::from).collect();
let message =
localization::message(keys::IR_CIRCULAR_DEPENDENCY).with_arg("cycle", format!("{cycle:?}"));
IrGenError::CircularDependency {
cycle,
missing_dependencies: Vec::new(),
message,
}
}
fn circular_dependency_error() -> IrGenError {
circular_dependency_error_for(vec!["a", "b", "a"])
}
#[rstest]
fn render_plain_error_json_records_cause_chain() -> Result<()> {
let error = anyhow::anyhow!("top level failure").context("outer context");
let document = render_error_json(error.as_ref())?;
let value = parse_json_value(&document)?;
let diagnostic = first_diagnostic(&value)?;
let schema_version = value
.get("schema_version")
.and_then(Value::as_i64)
.context("schema version should be present")?;
let generator_name = value
.get("generator")
.and_then(Value::as_object)
.and_then(|generator| generator.get("name"))
.and_then(Value::as_str)
.context("generator name should be present")?;
let message = diagnostic
.get("message")
.and_then(Value::as_str)
.context("message should be present")?;
let causes = diagnostic
.get("causes")
.context("causes should be present")?;
let labels = diagnostic
.get("labels")
.context("labels should be present")?;
ensure!(schema_version == 1, "schema version should be stable",);
ensure!(
generator_name == "netsuke",
"generator name should be present",
);
ensure!(
message == "outer context",
"plain errors should use the top-level message"
);
ensure!(
causes == &Value::from(vec![String::from("top level failure")]),
"plain errors should record the error cause chain",
);
ensure!(
labels == &Value::Array(Vec::new()),
"plain errors should not fabricate labels",
);
Ok(())
}
#[rstest]
fn render_runner_diagnostic_json_records_help_without_spans(
en_localizer: EnLocalizer,
) -> Result<()> {
let _en_localizer = en_localizer;
let document = render_diagnostic_json(&manifest_not_found_error())?;
let value = parse_json_value(&document)?;
let diagnostic = first_diagnostic(&value)?;
let code = diagnostic
.get("code")
.and_then(Value::as_str)
.context("diagnostic code should be present")?;
let help = diagnostic
.get("help")
.and_then(Value::as_str)
.context("diagnostic help should be present")?;
let source = diagnostic
.get("source")
.context("source should be present")?;
let labels = diagnostic
.get("labels")
.context("labels should be present")?;
ensure!(
code == "netsuke::runner::manifest_not_found",
"runner diagnostic code should be stable",
);
ensure!(
help == "Ensure the manifest exists or pass `--file` with the correct path.",
"runner diagnostics should include help text",
);
ensure!(
source.is_null(),
"manifest-not-found should not claim a source file span"
);
ensure!(
labels == &Value::Array(Vec::new()),
"manifest-not-found should not include labels",
);
Ok(())
}
#[rstest]
fn render_circular_dependency_display_matches_snapshot(en_localizer: EnLocalizer) {
let _en_localizer = en_localizer;
let rendered = circular_dependency_error().to_string();
snapshot_settings().bind(|| {
assert_snapshot!("circular_dependency_display", rendered);
});
}
#[rstest]
fn render_circular_dependency_json_matches_snapshot(en_localizer: EnLocalizer) -> Result<()> {
let _en_localizer = en_localizer;
let error = anyhow::Error::new(circular_dependency_error())
.context(localization::message(keys::RUNNER_CONTEXT_BUILD_GRAPH));
let document = render_error_json(error.as_ref())?;
let value = parse_json_value(&document)?;
let rendered =
serde_json::to_string_pretty(&value).context("render diagnostic JSON snapshot value")?;
snapshot_settings().bind(|| {
assert_snapshot!("circular_dependency_json", rendered);
});
Ok(())
}
#[rstest]
fn render_circular_dependency_json_has_expected_shape(en_localizer: EnLocalizer) -> Result<()> {
let _en_localizer = en_localizer;
let error = anyhow::Error::new(circular_dependency_error())
.context(localization::message(keys::RUNNER_CONTEXT_BUILD_GRAPH));
let document = render_error_json(error.as_ref())?;
let value = parse_json_value(&document)?;
let diagnostic = first_diagnostic(&value)?;
let causes = diagnostic
.get("causes")
.and_then(Value::as_array)
.context("circular dependency JSON must include a causes array")?;
let message = diagnostic
.get("message")
.and_then(Value::as_str)
.context("circular dependency JSON must include a message string")?;
ensure!(
!causes.is_empty(),
"circular dependency must have at least one cause"
);
ensure!(
!message.is_empty(),
"circular dependency message must not be empty"
);
Ok(())
}
#[rstest]
fn render_manifest_parse_diagnostic_matches_snapshot(en_localizer: EnLocalizer) -> Result<()> {
let _en_localizer = en_localizer;
let err = manifest::from_str("targets:\n\t- name: test\n")
.expect_err("invalid YAML should fail to parse");
let manifest_err = err
.downcast_ref::<manifest::ManifestError>()
.context("expected ManifestError")?;
let document = render_diagnostic_json(manifest_err)?;
let value = parse_json_value(&document)?;
let rendered =
serde_json::to_string_pretty(&value).context("render diagnostic JSON snapshot value")?;
snapshot_settings().bind(|| {
assert_snapshot!("manifest_parse_error", rendered);
});
Ok(())
}
fn arb_unique_nodes(min: usize, max: usize) -> impl Strategy<Value = Vec<camino::Utf8PathBuf>> {
proptest::collection::vec("[a-z]", min..=max)
.prop_filter("nodes must be unique", |v| {
let set: std::collections::HashSet<_> = v.iter().collect();
set.len() == v.len()
})
.prop_map(|v| v.into_iter().map(camino::Utf8PathBuf::from).collect())
}
fn closed_cycle_slices(nodes: &[camino::Utf8PathBuf]) -> Vec<&str> {
let mut cycle_nodes: Vec<&str> = nodes.iter().map(|node| node.as_str()).collect();
if let Some(&first) = cycle_nodes.first() {
cycle_nodes.push(first);
}
cycle_nodes
}
proptest! {
#[test]
fn prop_circular_dependency_display_is_nonempty_and_contains_nodes(
nodes in arb_unique_nodes(2, 8),
) {
let cycle_nodes = closed_cycle_slices(&nodes);
let error = circular_dependency_error_for(cycle_nodes);
let rendered = error.to_string();
prop_assert!(
!rendered.is_empty(),
"Display output must not be empty for a {}-node cycle",
nodes.len(),
);
for node in &nodes {
prop_assert!(
rendered.contains(node.as_str()),
"Display output must contain node {node:?} for a {}-node cycle; got: {rendered:?}",
nodes.len(),
);
}
}
#[test]
fn prop_render_circular_dependency_json_is_valid_for_arbitrary_cycles(
nodes in arb_unique_nodes(2, 8),
) {
let cycle_nodes = closed_cycle_slices(&nodes);
let error = anyhow::Error::new(circular_dependency_error_for(cycle_nodes))
.context(localization::message(keys::RUNNER_CONTEXT_BUILD_GRAPH));
let document = render_error_json(error.as_ref())
.expect("render_error_json must not fail for a well-formed CircularDependency");
let value: serde_json::Value = serde_json::from_str(&document)
.expect("render_error_json must produce valid JSON");
let diagnostics = value
.get("diagnostics")
.and_then(serde_json::Value::as_array)
.expect("JSON must contain a diagnostics array");
prop_assert!(
!diagnostics.is_empty(),
"diagnostics array must not be empty for a {}-node cycle",
nodes.len(),
);
let message = diagnostics
.first()
.and_then(|diagnostic| diagnostic.get("message"))
.and_then(serde_json::Value::as_str)
.expect("first diagnostic must contain a message string");
prop_assert!(
!message.is_empty(),
"diagnostic message must not be empty for a {}-node cycle",
nodes.len(),
);
}
}