use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::Path;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use crate::diagnostics::{categories, Diagnostic, DiagnosticStage, Severity, ValidationReport};
use crate::error::{Error, Result};
use crate::model::{ExtensionMap, Metadata};
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CapabilityDecl {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default)]
pub optional: bool,
#[serde(default, flatten)]
pub extensions: ExtensionMap,
}
#[cfg(feature = "jsonschema")]
impl schemars::JsonSchema for CapabilityDecl {
fn schema_name() -> String {
"CapabilityDecl".to_owned()
}
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::Schema::Object(schemars::schema::SchemaObject {
subschemas: Some(Box::new(schemars::schema::SubschemaValidation {
any_of: Some(vec![
String::json_schema(gen),
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::Object.into()),
..Default::default()
}
.into(),
]),
..Default::default()
})),
..Default::default()
})
}
}
impl<'de> Deserialize<'de> for CapabilityDecl {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct CapabilityDeclVisitor;
impl<'de> Visitor<'de> for CapabilityDeclVisitor {
type Value = CapabilityDecl;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a capability id string or capability object")
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(CapabilityDecl {
id: value.to_owned(),
category: None,
optional: false,
extensions: ExtensionMap::default(),
})
}
fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
Ok(CapabilityDecl {
id: value,
category: None,
optional: false,
extensions: ExtensionMap::default(),
})
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut id: Option<String> = None;
let mut category: Option<String> = None;
let mut optional = false;
let mut extensions = ExtensionMap::default();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"id" => {
if id.is_some() {
return Err(de::Error::duplicate_field("id"));
}
id = Some(map.next_value()?);
}
"category" => {
if category.is_some() {
return Err(de::Error::duplicate_field("category"));
}
category = Some(map.next_value()?);
}
"optional" => {
optional = map.next_value()?;
}
other => {
let value = map.next_value()?;
extensions.insert(other.to_owned(), value);
}
}
}
let id = id.ok_or_else(|| de::Error::missing_field("id"))?;
Ok(CapabilityDecl {
id,
category,
optional,
extensions,
})
}
}
deserializer.deserialize_any(CapabilityDeclVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct CapabilityProfile {
#[serde(alias = "profile")]
pub identity: String,
#[serde(default)]
pub dpcs_version: String,
#[serde(default)]
pub capabilities: Vec<CapabilityDecl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub limitations: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
#[serde(default, flatten)]
pub extensions: ExtensionMap,
}
#[deprecated(note = "use CapabilityProfile")]
pub type OrchestratorCapabilities = CapabilityProfile;
impl CapabilityProfile {
pub fn new(identity: impl Into<String>, dpcs_version: impl Into<String>) -> Self {
Self {
identity: identity.into(),
dpcs_version: dpcs_version.into(),
capabilities: Vec::new(),
limitations: Vec::new(),
metadata: None,
extensions: ExtensionMap::default(),
}
}
pub fn from_yaml_str(input: &str) -> Result<Self> {
serde_yaml::from_str(input).map_err(|err| profile_parse_error(err.to_string()))
}
pub fn from_json_str(input: &str) -> Result<Self> {
serde_json::from_str(input).map_err(|err| profile_parse_error(err.to_string()))
}
pub fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let contents = fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
Self::from_yaml_str(&contents).map_err(|err| with_source_path(err, path))
}
pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let contents = fs::read_to_string(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
Self::from_json_str(&contents).map_err(|err| with_source_path(err, path))
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
match ext.as_deref() {
Some("yaml") | Some("yml") => Self::from_yaml_file(path),
Some("json") => Self::from_json_file(path),
_ => Err(Error::UnsupportedFormat {
path: path.to_path_buf(),
}),
}
}
pub fn to_yaml_str(&self) -> Result<String> {
serde_yaml::to_string(self)
.map_err(|err| Error::Serialization(format!("failed to serialize YAML: {err}")))
}
pub fn to_json_str(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|err| Error::Serialization(format!("failed to serialize JSON: {err}")))
}
}
pub fn validate_profile(profile: &CapabilityProfile) -> ValidationReport {
let mut report = ValidationReport::new();
if profile.identity.trim().is_empty() {
report.push(
Diagnostic::capability_error(
"DPCS-CAP-003",
categories::CAPABILITY,
"capability profile must declare a non-empty identity",
)
.with_object_ref("identity")
.with_remediation("Set identity (or legacy profile) to the orchestrator name"),
);
}
if profile.dpcs_version.trim().is_empty() {
report.push(
Diagnostic::capability_error(
"DPCS-CAP-004",
categories::CAPABILITY,
"capability profile must declare a non-empty dpcsVersion",
)
.with_object_ref("dpcsVersion")
.with_remediation("Set dpcsVersion to a supported DPCS specification version"),
);
} else if !crate::model::is_valid_version(&profile.dpcs_version) {
report.push(
Diagnostic::capability_error(
"DPCS-VER-003",
categories::VERSIONING,
format!(
"capability profile dpcsVersion `{}` is not a valid SemVer-compatible identifier",
profile.dpcs_version
),
)
.with_object_ref("dpcsVersion")
.with_remediation(
"Use MAJOR.MINOR.PATCH with optional pre-release (for example `1.0.0-draft`)",
),
);
}
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for (index, capability) in profile.capabilities.iter().enumerate() {
let object_ref = format!("capabilities[{index}]");
if capability.id.trim().is_empty() {
report.push(
Diagnostic::capability_error(
"DPCS-CAP-001",
categories::CAPABILITY,
"capability id must not be empty",
)
.with_object_ref(format!("{object_ref}.id")),
);
continue;
}
let key = capability.id.trim().to_owned();
if let Some(first_index) = seen.get(&key) {
report.push(
Diagnostic::capability_error(
"DPCS-CAP-002",
categories::CAPABILITY,
format!("duplicate capability id `{key}`"),
)
.with_object_ref(object_ref)
.with_remediation(format!(
"Capability `{key}` was first declared at capabilities[{first_index}]"
)),
);
} else {
seen.insert(key, index);
}
}
report.sort_deterministic();
report
}
fn profile_parse_error(message: String) -> Error {
let mut report = ValidationReport::new();
report.push(Diagnostic {
id: "DPCS-PARSE-002".to_owned(),
severity: Severity::Error,
stage: DiagnosticStage::Parse,
category: categories::SYNTAX.to_owned(),
message: format!("invalid capability profile: {message}"),
object_ref: None,
remediation: Some(
"Provide identity (or profile), dpcsVersion, and capabilities[] with ids".to_owned(),
),
source_location: None,
related_diagnostics: Vec::new(),
metadata: None,
});
Error::InvalidDocument { report }
}
fn with_source_path(err: Error, path: &Path) -> Error {
match err {
Error::InvalidDocument { mut report } => {
let location = path.display().to_string();
for diagnostic in &mut report.diagnostics {
if diagnostic.source_location.is_none() {
diagnostic.source_location = Some(location.clone());
}
}
Error::InvalidDocument { report }
}
other => other,
}
}