use std::path::PathBuf;
use std::time::Duration;
use noyalib::compat::serde_yaml;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use super::{EndpointRef, Provisioning, ref_scheme};
#[derive(Debug, Clone)]
pub(super) struct RawEndpointRef {
pub(super) endpoint: String,
pub(super) provisioning: Option<String>,
pub(super) bind_var: Option<String>,
}
impl RawEndpointRef {
fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
match value {
serde_yaml::Value::String(endpoint) => Ok(Self {
endpoint,
provisioning: None,
bind_var: None,
}),
serde_yaml::Value::Mapping(ref map) => {
let mut endpoint: Option<String> = None;
let mut provisioning: Option<String> = None;
let mut bind_var: Option<String> = None;
for (key, value) in map {
match key.as_str() {
"endpoint" | "provisioning" | "bindVar" => {
let text = value.as_str().ok_or_else(|| {
format!(
"endpoint reference `{key}` must be a string, got {value:?}"
)
})?;
match key.as_str() {
"endpoint" => endpoint = Some(text.to_string()),
"provisioning" => provisioning = Some(text.to_string()),
_ => bind_var = Some(text.to_string()),
}
}
other => {
return Err(format!("unknown field `{other}` in endpoint reference"));
}
}
}
let endpoint = endpoint
.ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
Ok(Self {
endpoint,
provisioning,
bind_var,
})
}
other => Err(format!(
"endpoint reference must be a string or a map, got {other:?}"
)),
}
}
}
impl<'de> Deserialize<'de> for RawEndpointRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DocError {
#[error("failed to read test document {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid test document: {0}")]
Yaml(String),
#[error("unknown field in test document: {0}")]
UnknownField(String),
#[error(
"doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
)]
NotTestDocument {
path: PathBuf,
},
#[error("doc-validation: scenario document must declare a `scenario:` section")]
MissingScenario,
#[error(
"doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
)]
MixedVocabulary {
found: String,
},
#[error(
"exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
)]
RouteSourceMissing,
#[error("route sources {present} are mutually exclusive; exactly one route source is required")]
RouteSourceConflict {
present: String,
},
#[error("doc-validation: scenario[{index}]: {message}")]
Validation {
index: usize,
message: String,
},
#[error(
"doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
)]
UnsupportedProvisioning {
value: String,
endpoint: String,
},
#[error(
"doc-validation: endpoint `{endpoint}` declares `bindVar` but its `{ref_scheme}:` reference binds no harness partner, so the variable would never receive a bound authority (exit-2 doc-validation class)"
)]
ProvisioningWithoutAuthority {
endpoint: String,
ref_scheme: String,
},
#[error(
"doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
)]
ReservedEnvKey {
key: String,
endpoint: String,
},
#[error("doc-validation: partners[{endpoint}]: {message}")]
Partners {
endpoint: String,
message: String,
},
#[error("doc-validation: inline routes: {0}")]
InlineRoutes(String),
#[error(
"doc-validation: inline `routes` are rejected at load: declare `routeFiles` instead (inline definitions cannot boot in the scenario tier; exit 2)"
)]
InlineRoutesRejected,
#[error(
"doc-validation: scenario[{index}]: `expectReply` is only valid on a `direct:` send, not `{scheme}` (exit 2)"
)]
ExpectReplyOnUnsupportedSend {
index: usize,
scheme: String,
},
#[error("doc-validation: malformed `logs` block: {detail}")]
LogsBlock {
detail: String,
},
}
pub(super) fn classify_yaml_error(raw: &str) -> DocError {
if raw.contains("unknown field") {
return DocError::UnknownField(raw.to_string());
}
DocError::Yaml(raw.to_string())
}
pub(super) fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
let provisioning = match raw.provisioning.as_deref() {
None => None,
Some("harness") => Some(Provisioning::Harness),
Some(value) => {
return Err(DocError::UnsupportedProvisioning {
value: value.to_string(),
endpoint: raw.endpoint.clone(),
});
}
};
if provisioning == Some(Provisioning::Harness)
&& raw.bind_var.is_some()
&& let Some(scheme) = ref_scheme(&raw.endpoint)
&& (scheme == "direct" || scheme == "fake")
{
return Err(DocError::ProvisioningWithoutAuthority {
endpoint: raw.endpoint.clone(),
ref_scheme: scheme.to_string(),
});
}
Ok(EndpointRef {
endpoint: raw.endpoint,
provisioning,
bind_var: raw.bind_var,
})
}
pub(super) fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
humantime::parse_duration(raw).map_err(|e| DocError::Validation {
index,
message: format!("invalid {field} `{raw}`: {e}"),
})
}