use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::model::AssetId;
use crate::model::compiler::RobotParts;
use crate::model::identity::{
CapabilityId, ComponentInstanceId, ComponentTypeId, LinkId, RobotId, ServiceId,
};
use source::SourceError;
pub mod build_requirements;
pub mod schema;
pub mod source;
mod normalized;
#[cfg(test)]
mod source_generation_proof;
mod urdf_dto;
pub use urdf_dto::{JointEnd, StructuralKind, UrdfError};
#[derive(Debug, Clone)]
pub struct SourceSet {
pub project_root: PathBuf,
pub robot_manifest: PathBuf,
pub component_roots: BTreeMap<String, PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedSources {
robot_manifest: PathBuf,
robot_root: PathBuf,
component_roots: BTreeMap<String, PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum CompileError {
#[error("failed to resolve compiler input {}: {source}", path.display())]
Input {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("no resolved component root for type '{component_type}'")]
UnresolvedComponentRoot { component_type: String },
#[error("failed to compile {} document: {source}", source.kind())]
Document {
#[source]
source: SourceError,
},
#[error("failed to compile component type '{component_type}' at {}: {source}", root.display())]
Component {
component_type: String,
root: PathBuf,
#[source]
source: Box<CompileError>,
},
#[error("failed to compile structure document {}: {source}", path.display())]
Structure {
path: PathBuf,
#[source]
source: UrdfError,
},
#[error("component instance '{instance}' references unresolved type '{component_type}'")]
UnknownComponentType {
instance: String,
component_type: String,
},
#[error(
"component instance '{instance}' parameters reference unknown capability \
'{capability_id}'"
)]
UnknownCapability {
instance: String,
capability_id: String,
},
#[error(
"component instance '{instance}' role assignments reference unknown capability \
'{capability_id}'"
)]
UnknownRoleCapability {
instance: String,
capability_id: String,
},
#[error(
"component instance '{instance}' parameter '{capability_id}' kind '{authored}' does not \
match '{declared}'"
)]
CapabilityKindMismatch {
instance: String,
capability_id: String,
authored: crate::model::component::capability::CapabilityKind,
declared: crate::model::component::capability::CapabilityKind,
},
#[error("failed to normalize authored {authored} into its canonical form: {source}")]
Transcode {
authored: &'static str,
#[source]
source: serde_json::Error,
},
#[error("failed to construct canonical robot from {}: {source}", path.display())]
CanonicalModel {
path: PathBuf,
#[source]
source: crate::model::ModelError,
},
#[error("failed to compile runtime assets below {}: {source}", root.display())]
Assets {
root: PathBuf,
#[source]
source: AssetError,
},
}
#[derive(Debug, thiserror::Error)]
pub enum AssetError {
#[error("failed to read {}: {source}", path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("asset source tree contains forbidden symlink {}", path.display())]
ForbiddenSymlink { path: PathBuf },
#[error("unsupported asset source entry {}", path.display())]
UnsupportedEntry { path: PathBuf },
#[error("asset source entry name is not UTF-8: {}", path.display())]
NotUtf8 { path: PathBuf },
#[error("invalid logical asset id: {source}")]
Id {
#[source]
source: crate::model::ModelError,
},
#[error("duplicate compiled asset '{id}'", id = id.as_str())]
Duplicate { id: AssetId },
#[error("canonical model references missing compiled asset '{id}'", id = id.as_str())]
Missing { id: AssetId },
}
#[derive(Debug, Clone)]
pub struct CompiledProject {
robot: crate::model::Robot,
assets: CompiledAssets,
}
#[derive(Debug, Clone, Default)]
pub struct CompiledAssets(BTreeMap<AssetId, Vec<u8>>);
impl SourceSet {
pub fn compile(
self,
official_services: impl IntoIterator<Item = ServiceId>,
) -> Result<CompiledProject, CompileError> {
let project_root = canonicalize(&self.project_root)?;
let robot_manifest = canonicalize(&self.robot_manifest)?;
let manifest = source::robot::Manifest::load(&robot_manifest)
.map_err(|source| CompileError::Document { source })?
.normalize()?;
let resolved = ResolvedSources {
robot_manifest,
robot_root: project_root.clone(),
component_roots: self.component_roots,
};
let robot = resolved.compile_model(&manifest, official_services)?;
let assets = resolved.compile_assets(&project_root, &manifest, &robot)?;
Ok(CompiledProject { robot, assets })
}
}
#[must_use]
pub fn probe(sources: SourceSet, official_services: &[ServiceId]) -> serde_json::Value {
let rejected = |error: String| serde_json::json!({"accepted": false, "error": error});
let compiled = match sources.compile(official_services.iter().cloned()) {
Ok(compiled) => compiled,
Err(error) => return rejected(error.to_string()),
};
let (document, assets) = compiled.into_document();
let assets = assets
.iter()
.map(|(id, bytes)| serde_json::json!({"bytes": bytes.len(), "id": id.as_str()}))
.collect::<Vec<_>>();
match serde_json::to_value(&document) {
Ok(robot) => serde_json::json!({
"accepted": true,
"canonical": {"assets": assets, "robot": robot},
}),
Err(error) => rejected(format!(
"failed to render the compiled manifest document: {error}"
)),
}
}
pub(crate) fn referenced_asset_ids(robot: &crate::model::Robot) -> BTreeSet<AssetId> {
let mut ids = robot
.structure()
.asset_ids()
.cloned()
.collect::<BTreeSet<_>>();
for component in robot.components() {
ids.extend(component.component_type().structure().asset_ids().cloned());
}
ids
}
fn canonicalize(path: &Path) -> Result<PathBuf, CompileError> {
path.canonicalize().map_err(|source| CompileError::Input {
path: path.to_path_buf(),
source,
})
}
impl ResolvedSources {
fn component_root(&self, component_type: &str) -> Result<&PathBuf, CompileError> {
self.component_roots.get(component_type).ok_or_else(|| {
CompileError::UnresolvedComponentRoot {
component_type: component_type.to_string(),
}
})
}
fn compile_model(
&self,
manifest: &normalized::Robot,
official_services: impl IntoIterator<Item = ServiceId>,
) -> Result<crate::model::Robot, CompileError> {
let mut component_types = BTreeMap::new();
for component_type in manifest.used_component_types() {
let root = self.component_root(component_type)?.clone();
let component =
self.compile_component_type(component_type, &root)
.map_err(|source| CompileError::Component {
component_type: component_type.to_string(),
root,
source: Box::new(source),
})?;
component_types.insert(
self.identity(ComponentTypeId::new(component_type))?,
component,
);
}
let mut components = BTreeMap::new();
for (id, authored) in &manifest.instances {
let component = component_types
.get(authored.component_type.as_str())
.ok_or_else(|| CompileError::UnknownComponentType {
instance: id.clone(),
component_type: authored.component_type.clone(),
})?;
let mut direction_signs = BTreeMap::new();
let mut roles = BTreeMap::new();
for (capability_id, authored_roles) in &authored.roles {
if component.capability(capability_id).is_none() {
return Err(CompileError::UnknownRoleCapability {
instance: id.clone(),
capability_id: capability_id.clone(),
});
}
let canonical_id = self.identity(CapabilityId::new(capability_id))?;
roles.insert(canonical_id, authored_roles.clone());
}
for (capability_id, parameters) in &authored.parameters {
let declared = component.capability(capability_id).ok_or_else(|| {
CompileError::UnknownCapability {
instance: id.clone(),
capability_id: capability_id.clone(),
}
})?;
if declared.kind() != parameters.kind {
return Err(CompileError::CapabilityKindMismatch {
instance: id.clone(),
capability_id: capability_id.clone(),
authored: parameters.kind,
declared: declared.kind(),
});
}
direction_signs.insert(
self.identity(CapabilityId::new(capability_id))?,
parameters.direction_sign,
);
}
let driver = authored
.driver
.clone()
.map(|driver| crate::model::compiler::driver(driver.connection, driver.config));
components.insert(
self.identity(ComponentInstanceId::new(id))?,
crate::model::compiler::component_instance(
self.identity(ComponentTypeId::new(&authored.component_type))?,
LinkId::new(&authored.mount_link),
direction_signs,
roles,
driver,
),
);
}
let structure_path = self.robot_relative(&manifest.structure)?;
let structure = urdf_dto::Structure::load(&structure_path)
.and_then(|structure| structure.into_canonical(None))
.map_err(|source| CompileError::Structure {
path: structure_path,
source,
})?;
crate::model::compiler::robot(RobotParts {
id: self.identity(RobotId::new(&manifest.id))?,
kinematic: manifest.kinematic.clone(),
motion_limits: manifest.motion_limits,
services: self.compile_services(manifest, official_services)?,
components,
component_types,
structure,
})
.map_err(|source| CompileError::CanonicalModel {
path: self.robot_manifest.clone(),
source,
})
}
fn compile_services(
&self,
manifest: &normalized::Robot,
official_services: impl IntoIterator<Item = ServiceId>,
) -> Result<BTreeMap<ServiceId, crate::model::robot::Service>, CompileError> {
let mut services = official_services
.into_iter()
.map(|id| (id, crate::model::compiler::service(None)))
.collect::<BTreeMap<_, _>>();
for (id, config) in &manifest.services {
services.insert(
self.identity(ServiceId::new(id))?,
crate::model::compiler::service(config.clone()),
);
}
Ok(services)
}
fn compile_component_type(
&self,
component_type: &str,
configured_root: &Path,
) -> Result<crate::model::component::Component, CompileError> {
let root = canonicalize(configured_root)?;
let authored = source::component::Manifest::load(&root)
.map_err(|source| CompileError::Document { source })?
.normalize(component_type, &root)?;
let structure_path = root.join("structure.urdf");
let structure = urdf_dto::Structure::load(&structure_path)
.and_then(|structure| structure.into_canonical_fragment(component_type))
.map_err(|source| CompileError::Structure {
path: structure_path,
source,
})?;
let simulation_path = root.join("simulation.yaml");
let simulation = if simulation_path.is_file() {
let simulation = source::simulation::Manifest::load(&simulation_path)
.map_err(|source| CompileError::Document { source })?
.normalize()?;
Some(crate::model::compiler::simulation(
simulation.capabilities,
simulation.links,
))
} else {
None
};
Ok(crate::model::compiler::component(
authored.capabilities,
structure,
simulation,
))
}
fn identity<T, E>(&self, result: Result<T, E>) -> Result<T, CompileError>
where
E: Into<crate::model::ModelError>,
{
result.map_err(|source| CompileError::CanonicalModel {
path: self.robot_manifest.clone(),
source: source.into(),
})
}
fn robot_relative(&self, relative: &Path) -> Result<PathBuf, CompileError> {
canonicalize(&self.robot_root.join(relative))
}
fn compile_assets(
&self,
project_root: &Path,
manifest: &normalized::Robot,
robot: &crate::model::Robot,
) -> Result<CompiledAssets, CompileError> {
let mut assets = CompiledAssets::default();
let mut collect = |root: &Path, staged: &str| -> Result<(), CompileError> {
collect_files(&root.join("meshes"), staged, &mut assets).map_err(|source| {
CompileError::Assets {
root: root.to_path_buf(),
source,
}
})
};
collect(project_root, "robot/meshes")?;
for component_type in manifest.used_component_types() {
let root = self.component_root(component_type)?.clone();
collect(&root, &format!("components/{component_type}/meshes"))?;
}
for id in referenced_asset_ids(robot) {
if !assets.0.contains_key(&id) {
return Err(CompileError::Assets {
root: project_root.to_path_buf(),
source: AssetError::Missing { id },
});
}
}
Ok(assets)
}
}
impl CompiledProject {
#[must_use]
pub const fn robot(&self) -> &crate::model::Robot {
&self.robot
}
#[must_use]
pub const fn assets(&self) -> &CompiledAssets {
&self.assets
}
#[must_use]
pub fn into_document(self) -> (crate::model::ManifestDocument, CompiledAssets) {
(crate::model::ManifestDocument::new(self.robot), self.assets)
}
#[must_use]
pub fn into_parts(self) -> (crate::model::Robot, CompiledAssets) {
(self.robot, self.assets)
}
}
impl CompiledAssets {
fn insert(&mut self, id: AssetId, bytes: Vec<u8>) -> Result<(), AssetError> {
if self.0.insert(id.clone(), bytes).is_some() {
return Err(AssetError::Duplicate { id });
}
Ok(())
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = (&AssetId, &[u8])> {
self.0.iter().map(|(id, bytes)| (id, bytes.as_slice()))
}
#[must_use]
pub fn into_map(self) -> BTreeMap<AssetId, Vec<u8>> {
self.0
}
}
fn collect_files(
source_root: &Path,
staged_root: &str,
output: &mut CompiledAssets,
) -> Result<(), AssetError> {
if !source_root.is_dir() {
return Ok(());
}
let read = |path: &Path| {
std::fs::read_dir(path)
.and_then(std::iter::Iterator::collect::<std::io::Result<Vec<_>>>)
.map_err(|source| AssetError::Read {
path: path.to_path_buf(),
source,
})
};
let mut entries = read(source_root)?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let source = entry.path();
let metadata = std::fs::symlink_metadata(&source).map_err(|error| AssetError::Read {
path: source.clone(),
source: error,
})?;
if metadata.file_type().is_symlink() {
return Err(AssetError::ForbiddenSymlink { path: source });
}
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
return Err(AssetError::NotUtf8 { path: source });
};
let staged = format!("{staged_root}/{name}");
if metadata.is_dir() {
collect_files(&source, &staged, output)?;
} else if metadata.is_file() {
let id = AssetId::new(staged).map_err(|source| AssetError::Id { source })?;
let bytes = std::fs::read(&source).map_err(|error| AssetError::Read {
path: source.clone(),
source: error,
})?;
output.insert(id, bytes)?;
} else {
return Err(AssetError::UnsupportedEntry { path: source });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn asset_ids_are_normalized() {
for invalid in ["", "/a", "../a", "a/../b", "a\\b", "a//b"] {
assert!(crate::model::AssetId::new(invalid).is_err(), "{invalid}");
}
assert_eq!(
crate::model::AssetId::new("meshes/base.stl")
.unwrap()
.as_str(),
"meshes/base.stl"
);
}
}