use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use serde::Deserialize;
use thiserror::Error;
use crate::configuration::Configuration;
pub const SCHEMA_NAME_DEFAULT: &str = "nbspec-default";
pub const SCHEMA_FILE: &str = "schema.toml";
const SCHEMA_TOML_DEFAULT: &str = include_str!("schemata/default.toml");
static SCHEMA_DEFAULT: LazyLock<WorkflowSchema> =
LazyLock::new(|| parse_schema(SCHEMA_TOML_DEFAULT).expect("embedded default schema is valid"));
#[derive(Debug, Error)]
pub enum SchemaError {
#[error("schema not found: {0}")]
NotFound(String),
#[error("schema parse failure: {0}")]
Parse(#[from] toml::de::Error),
#[error("schema invalid: {0}")]
Invalid(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Clone, Debug, Deserialize)]
pub struct WorkflowSchema {
pub name: String,
pub version: u32,
#[serde(default)]
pub description: String,
pub artifacts: Vec<Artifact>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Artifact {
pub id: String,
pub generates: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub instruction: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub grammar: Option<ArtifactGrammar>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum ArtifactGrammar {
DeltaSpecification,
}
impl WorkflowSchema {
pub fn artifact(&self, id: &str) -> Option<&Artifact> {
self.artifacts.iter().find(|artifact| artifact.id == id)
}
pub fn authoring_order(&self) -> Vec<&Artifact> {
let mut ordered = Vec::with_capacity(self.artifacts.len());
let mut placed: HashSet<&str> = HashSet::new();
while ordered.len() < self.artifacts.len() {
let mut progressed = false;
for artifact in &self.artifacts {
if placed.contains(artifact.id.as_str()) {
continue;
}
if artifact
.requires
.iter()
.all(|dependency| placed.contains(dependency.as_str()))
{
placed.insert(artifact.id.as_str());
ordered.push(artifact);
progressed = true;
}
}
debug_assert!(progressed, "validated schemas have no cycles");
if !progressed {
break;
}
}
ordered
}
}
pub fn parse_schema(content: &str) -> Result<WorkflowSchema, SchemaError> {
let schema: WorkflowSchema = toml::from_str(content)?;
validate_schema(&schema)?;
Ok(schema)
}
pub fn default_schema() -> WorkflowSchema {
SCHEMA_DEFAULT.clone()
}
pub fn resolve_schema(
explicit: Option<&str>,
configuration: &Configuration,
) -> Result<WorkflowSchema, SchemaError> {
let name = explicit
.or(configuration.schema.as_deref())
.unwrap_or(SCHEMA_NAME_DEFAULT);
if name == SCHEMA_NAME_DEFAULT {
return Ok(default_schema());
}
let path = configuration
.project_directory
.join("schemata")
.join(name)
.join(SCHEMA_FILE);
if !path.is_file() {
return Err(SchemaError::NotFound(name.to_string()));
}
parse_schema(&std::fs::read_to_string(&path)?)
}
fn validate_schema(schema: &WorkflowSchema) -> Result<(), SchemaError> {
let mut indices: HashMap<&str, usize> = HashMap::new();
for (index, artifact) in schema.artifacts.iter().enumerate() {
if indices.insert(artifact.id.as_str(), index).is_some() {
return Err(SchemaError::Invalid(format!(
"duplicate artifact id: {}",
artifact.id
)));
}
}
for artifact in &schema.artifacts {
validate_artifact_path(&artifact.id, "generates", &artifact.generates)?;
if let Some(target) = &artifact.target {
validate_artifact_path(&artifact.id, "target", target)?;
}
for dependency in &artifact.requires {
if !indices.contains_key(dependency.as_str()) {
return Err(SchemaError::Invalid(format!(
"artifact {} requires unknown artifact {}",
artifact.id, dependency
)));
}
}
}
detect_cycles(schema, &indices)
}
fn validate_artifact_path(artifact_id: &str, field: &str, path: &str) -> Result<(), SchemaError> {
match crate::configuration::confinement_violation(path) {
Some(detail) => Err(SchemaError::Invalid(format!(
"artifact {artifact_id} {field} path {path:?} {detail}"
))),
None => Ok(()),
}
}
fn detect_cycles(
schema: &WorkflowSchema,
indices: &HashMap<&str, usize>,
) -> Result<(), SchemaError> {
let count = schema.artifacts.len();
let mut in_degree = vec![0usize; count];
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); count];
for (index, artifact) in schema.artifacts.iter().enumerate() {
for dependency in &artifact.requires {
let dependency_index = indices[dependency.as_str()];
in_degree[index] += 1;
dependents[dependency_index].push(index);
}
}
let mut queue: Vec<usize> = (0..count).filter(|&index| in_degree[index] == 0).collect();
let mut processed = 0;
while let Some(index) = queue.pop() {
processed += 1;
for &dependent in &dependents[index] {
in_degree[dependent] -= 1;
if in_degree[dependent] == 0 {
queue.push(dependent);
}
}
}
if processed != count {
let cyclic: Vec<&str> = schema
.artifacts
.iter()
.enumerate()
.filter(|(index, _)| in_degree[*index] > 0)
.map(|(_, artifact)| artifact.id.as_str())
.collect();
return Err(SchemaError::Invalid(format!(
"dependency cycle among artifacts: {}",
cyclic.join(", ")
)));
}
Ok(())
}