use super::json::Document;
use std::path::{Path, PathBuf};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Entity {
Workflow,
Channel,
Connector,
}
impl Entity {
pub fn as_str(self) -> &'static str {
match self {
Entity::Workflow => "workflow",
Entity::Channel => "channel",
Entity::Connector => "connector",
}
}
pub fn classify(doc: &Value) -> Option<Entity> {
let obj = doc.as_object()?;
if obj.contains_key("tasks") {
return Some(Entity::Workflow);
}
if obj.contains_key("connector_type") {
return Some(Entity::Connector);
}
if obj.contains_key("channel_type") || obj.contains_key("protocol") {
return Some(Entity::Channel);
}
None
}
}
#[derive(Debug, Default, Clone)]
pub struct Boundary {
pub channels: Vec<String>,
pub connectors: Vec<String>,
pub models: Vec<String>,
}
impl Boundary {
pub fn allows_channel(&self, name: &str) -> bool {
self.channels.iter().any(|n| n == name)
}
pub fn allows_connector(&self, name: &str) -> bool {
self.connectors.iter().any(|n| n == name)
}
pub fn allows_model(&self, id: &str) -> bool {
self.models.iter().any(|n| n == id)
}
}
#[derive(Debug, Clone)]
pub struct Definition {
pub entity: Entity,
pub origin: String,
pub doc: Value,
pub spans: Option<Document>,
}
impl Definition {
pub fn locate(&self, path: &str) -> Option<(usize, usize)> {
let doc = self.spans.as_ref()?;
let prefix = format!("{}.", self.entity.as_str());
let path = path.strip_prefix(&prefix).unwrap_or(path);
let span = doc.locate(path)?;
Some(doc.line_col(span.start))
}
}
#[derive(Debug, Clone)]
pub struct PluginDefinition {
pub origin: String,
pub manifest: crate::plugin::Manifest,
pub digest: Option<String>,
pub component_path: Option<PathBuf>,
}
impl PluginDefinition {
pub fn from_file(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let manifest = crate::plugin::Manifest::parse(&text).map_err(|errors| {
errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect::<Vec<_>>()
.join("; ")
})?;
let component_path = manifest
.component
.as_deref()
.map(|rel| path.parent().unwrap_or_else(|| Path::new(".")).join(rel))
.filter(|p| p.is_file());
let digest = match &component_path {
Some(p) => Some(crate::plugin::WasmRuntime::digest(
&std::fs::read(p).map_err(|e| format!("reading '{}': {e}", p.display()))?,
)),
None => None,
};
Ok(Self {
origin: path.display().to_string(),
manifest,
digest,
component_path,
})
}
pub fn entries(&self) -> Vec<crate::engine::FunctionEntry> {
let binding = crate::engine::PluginBinding {
id: self.manifest.name.clone(),
version: 0,
digest: self.digest.clone().unwrap_or_default(),
abi: self.manifest.abi.clone(),
};
self.manifest.entries(&binding)
}
}
pub fn is_plugin_manifest(text: &str) -> bool {
toml::from_str::<toml::Value>(text).is_ok_and(|doc| {
doc.get("abi")
.and_then(toml::Value::as_str)
.is_some_and(|abi| abi.starts_with("orion:plugin@"))
})
}
#[derive(Debug, Clone)]
pub struct ModelDefinition {
pub origin: String,
pub manifest: crate::model::Manifest,
pub artifact_path: Option<PathBuf>,
pub digest: Option<String>,
pub artifact_bytes: Option<u64>,
pub graph: Option<Result<crate::model::GraphStats, String>>,
}
impl ModelDefinition {
pub fn from_file(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let manifest = crate::model::Manifest::parse(&text).map_err(|errors| {
errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect::<Vec<_>>()
.join("; ")
})?;
let artifact_path = manifest
.artifact
.as_deref()
.map(|rel| path.parent().unwrap_or_else(|| Path::new(".")).join(rel))
.filter(|p| p.is_file());
let (digest, artifact_bytes, graph) = match &artifact_path {
Some(p) => {
let bytes =
std::fs::read(p).map_err(|e| format!("reading '{}': {e}", p.display()))?;
(
Some(crate::crypto::sha256_digest(&bytes)),
Some(bytes.len() as u64),
Some(crate::model::read_stats(&bytes)),
)
}
None => (None, None, None),
};
Ok(Self {
origin: path.display().to_string(),
manifest,
artifact_path,
digest,
artifact_bytes,
graph,
})
}
pub fn from_manifest(origin: String, manifest: crate::model::Manifest) -> Self {
Self {
origin,
manifest,
artifact_path: None,
digest: None,
artifact_bytes: None,
graph: None,
}
}
pub fn manifest_entry(&self) -> Option<crate::model::ManifestEntry> {
let artifact_path = self.artifact_path.clone()?;
let digest = self.digest.clone()?;
let stats = match (&self.graph, self.artifact_bytes) {
(Some(Ok(graph)), Some(bytes)) => Some(crate::model::Stats::offline(graph, bytes)),
_ => None,
};
Some(crate::model::ManifestEntry {
manifest: self.manifest.clone(),
artifact_path,
digest,
stats,
})
}
}
#[derive(Debug, Default, Clone)]
pub struct DefinitionSet {
pub definitions: Vec<Definition>,
pub plugins: Vec<PluginDefinition>,
pub models: Vec<ModelDefinition>,
}
#[derive(Debug, Default)]
pub struct LoadReport {
pub findings: Vec<super::diagnostic::Diagnostic>,
pub shared: super::SharedDefinitions,
pub compiled: std::collections::BTreeMap<&'static str, usize>,
pub skipped: Vec<PathBuf>,
pub unparseable: Vec<(PathBuf, String)>,
}
impl DefinitionSet {
pub fn is_empty(&self) -> bool {
self.definitions.is_empty()
}
pub fn iter(&self, kind: Entity) -> impl Iterator<Item = &Definition> {
self.definitions.iter().filter(move |d| d.entity == kind)
}
pub fn count(&self, kind: Entity) -> usize {
self.iter(kind).count()
}
pub fn function_registry(&self) -> Result<crate::engine::FunctionRegistry, String> {
crate::engine::FunctionRegistry::builtin().with_entries(
self.plugins
.iter()
.flat_map(PluginDefinition::entries)
.collect(),
)
}
pub fn plugin_of(&self, function: &str) -> Option<&PluginDefinition> {
self.plugins
.iter()
.find(|p| function.starts_with(&format!("{}.", p.manifest.name)))
}
pub fn model_of(&self, id: &str) -> Option<&ModelDefinition> {
self.models.iter().find(|m| m.manifest.name == id)
}
pub fn add_model_dirs(
&mut self,
dirs: &[String],
) -> Result<Vec<super::diagnostic::Diagnostic>, String> {
let mut findings = Vec::new();
for dir in dirs {
let dir = Path::new(dir);
if dir.is_file() {
if let Ok(doc) = std::fs::read_to_string(dir)
.map_err(|e| e.to_string())
.and_then(|raw| serde_json::from_str::<Value>(&raw).map_err(|e| e.to_string()))
&& crate::model::is_model_manifest(&doc)
{
self.add_model_file(dir, &mut findings);
}
continue;
}
let mut paths = Vec::new();
walk_json_files(dir, &mut |path, parsed, _spans| {
if parsed.is_ok_and(|doc| crate::model::is_model_manifest(&doc)) {
paths.push(path);
}
})?;
paths.sort();
for path in paths {
self.add_model_file(&path, &mut findings);
}
}
Ok(findings)
}
fn add_model_file(&mut self, path: &Path, findings: &mut Vec<super::diagnostic::Diagnostic>) {
match ModelDefinition::from_file(path) {
Ok(model) => {
if let Some(existing) = self.model_of(&model.manifest.name) {
findings.push(super::diagnostic::Diagnostic::error(
"duplicate.model",
format!("model '{}'", model.manifest.name),
format!("declared twice: {} and {}", existing.origin, path.display()),
));
return;
}
self.models.push(model);
}
Err(reason) => findings.push(super::diagnostic::Diagnostic::error(
"parse.model",
path.display().to_string(),
format!("not a valid model manifest: {reason}"),
)),
}
}
pub fn add_plugin_dirs(
&mut self,
dirs: &[String],
) -> Result<Vec<super::diagnostic::Diagnostic>, String> {
let mut findings = Vec::new();
for dir in dirs {
let dir = Path::new(dir);
if dir.is_file() {
self.add_manifest_file(dir, &mut findings);
continue;
}
let mut paths = Vec::new();
walk_paths(dir, "toml", &mut |p| paths.push(p))?;
paths.sort();
for path in paths {
self.add_manifest_file(&path, &mut findings);
}
}
Ok(findings)
}
fn add_manifest_file(
&mut self,
path: &Path,
findings: &mut Vec<super::diagnostic::Diagnostic>,
) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
if !is_plugin_manifest(&text) {
return;
}
match PluginDefinition::from_file(path) {
Ok(plugin) => {
if let Some(existing) = self
.plugins
.iter()
.find(|p| p.manifest.name == plugin.manifest.name)
{
findings.push(super::diagnostic::Diagnostic::error(
"duplicate.plugin",
format!("plugin '{}'", plugin.manifest.name),
format!("declared twice: {} and {}", existing.origin, path.display()),
));
return;
}
self.plugins.push(plugin);
}
Err(reason) => findings.push(super::diagnostic::Diagnostic::error(
"parse.plugin",
path.display().to_string(),
format!("not a valid plugin manifest: {reason}"),
)),
}
}
pub fn from_entries(entries: impl IntoIterator<Item = (Entity, String, Value)>) -> Self {
Self {
definitions: entries
.into_iter()
.map(|(entity, origin, doc)| Definition {
entity,
origin,
doc,
spans: None,
})
.collect(),
plugins: Vec::new(),
models: Vec::new(),
}
}
pub fn from_directory(dir: &Path) -> Result<(Self, LoadReport), String> {
Self::from_directory_with(dir, &super::SharedDefinitions::default())
}
pub fn from_directory_raw(dir: &Path) -> Result<(Self, LoadReport), String> {
let mut set = DefinitionSet::default();
let mut report = LoadReport::default();
let mut shared_docs: Vec<(String, Value)> = Vec::new();
walk(dir, &mut set, &mut report, &mut shared_docs)?;
set.definitions.sort_by(|a, b| a.origin.cmp(&b.origin));
report.skipped.sort();
report.unparseable.sort();
shared_docs.sort_by(|a, b| a.0.cmp(&b.0));
let mut shared = super::SharedDefinitions::default();
for (origin, doc) in &shared_docs {
shared.merge(doc, origin, &mut report.findings);
}
report.shared = shared;
Ok((set, report))
}
pub fn from_directory_with(
dir: &Path,
seed: &super::SharedDefinitions,
) -> Result<(Self, LoadReport), String> {
let mut set = DefinitionSet::default();
let mut report = LoadReport::default();
let mut shared_docs: Vec<(String, Value)> = Vec::new();
walk(dir, &mut set, &mut report, &mut shared_docs)?;
set.definitions.sort_by(|a, b| a.origin.cmp(&b.origin));
report.skipped.sort();
report.unparseable.sort();
shared_docs.sort_by(|a, b| a.0.cmp(&b.0));
let mut shared = seed.clone();
for (origin, doc) in &shared_docs {
shared.merge(doc, origin, &mut report.findings);
}
if !shared.is_empty() {
for def in &mut set.definitions {
let origin = def.origin.clone();
let cx = super::compile::Cx {
shared: &shared,
origin: &origin,
};
for pass in super::compile::compile(&mut def.doc, &cx, &mut report.findings) {
*report.compiled.entry(pass).or_default() += 1;
}
}
}
report.shared = shared;
Ok((set, report))
}
}
pub(super) fn walk_json_files(
dir: &Path,
visit: &mut impl FnMut(std::path::PathBuf, Result<Value, String>, Option<Document>),
) -> Result<(), String> {
walk_json_paths(dir, &mut |path| {
let read = std::fs::read_to_string(&path).map_err(|e| e.to_string());
let parsed = read
.as_ref()
.map_err(|e| e.clone())
.and_then(|raw| serde_json::from_str::<Value>(raw).map_err(|e| e.to_string()));
let spans = read.ok().and_then(|raw| Document::parse(&raw).ok());
visit(path, parsed, spans);
})
}
pub fn json_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
walk_json_paths(dir, &mut |path| out.push(path))?;
out.sort();
Ok(out)
}
fn walk_json_paths(dir: &Path, visit: &mut impl FnMut(PathBuf)) -> Result<(), String> {
walk_paths(dir, "json", visit)
}
fn walk_paths(dir: &Path, ext: &str, visit: &mut impl FnMut(PathBuf)) -> Result<(), String> {
let entries =
std::fs::read_dir(dir).map_err(|e| format!("cannot read '{}': {e}", dir.display()))?;
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || name == "target" || name == "node_modules" {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
walk_paths(&path, ext, visit)?;
continue;
}
if file_type.is_symlink() && path.is_dir() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some(ext) {
continue;
}
visit(path);
}
Ok(())
}
fn walk(
dir: &Path,
set: &mut DefinitionSet,
report: &mut LoadReport,
shared_docs: &mut Vec<(String, Value)>,
) -> Result<(), String> {
let mut manifests = Vec::new();
walk_paths(dir, "toml", &mut |p| manifests.push(p))?;
manifests.sort();
for path in manifests {
set.add_manifest_file(&path, &mut report.findings);
}
walk_json_files(dir, &mut |path, parsed, spans| {
let doc = match parsed {
Ok(doc) => doc,
Err(e) => {
report.unparseable.push((path, e));
return;
}
};
match Entity::classify(&doc) {
Some(entity) => set.definitions.push(Definition {
entity,
origin: path.display().to_string(),
doc,
spans,
}),
None if super::SharedDefinitions::is_shared_document(&doc) => {
shared_docs.push((path.display().to_string(), doc));
}
None if crate::model::is_model_manifest(&doc) => {
set.add_model_file(&path, &mut report.findings);
}
None => report.skipped.push(path),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn entities_are_classified_by_shape() {
assert_eq!(
Entity::classify(&json!({"name": "w", "tasks": []})),
Some(Entity::Workflow)
);
assert_eq!(
Entity::classify(&json!({"name": "c", "connector_type": "db", "config": {}})),
Some(Entity::Connector)
);
assert_eq!(
Entity::classify(&json!({"name": "ch", "channel_type": "rest", "protocol": "http"})),
Some(Entity::Channel)
);
assert_eq!(Entity::classify(&json!({"data": {"amount": 5}})), None);
assert_eq!(Entity::classify(&json!([1, 2, 3])), None);
}
#[test]
fn a_connector_is_not_read_as_a_workflow() {
let connector = json!({
"name": "orders-db", "connector_type": "db",
"config": {"connection_string": "postgres://x"}
});
assert_eq!(Entity::classify(&connector), Some(Entity::Connector));
}
#[test]
fn a_directory_load_reports_what_it_skipped() {
let dir = std::env::temp_dir().join(format!("orion-defs-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("nested")).expect("test fixture");
std::fs::write(
dir.join("workflow.json"),
r#"{"name":"w","workflow_id":"w","tasks":[]}"#,
)
.expect("test fixture");
std::fs::write(
dir.join("nested/channel.json"),
r#"{"name":"c","channel_id":"c","channel_type":"rest","protocol":"http"}"#,
)
.expect("test fixture");
std::fs::write(dir.join("request.json"), r#"{"data":{}}"#).expect("test fixture");
std::fs::write(dir.join("broken.json"), r#"{not json"#).expect("test fixture");
std::fs::write(dir.join("README.md"), "not json at all").expect("test fixture");
let (set, report) = DefinitionSet::from_directory(&dir).expect("loads");
assert_eq!(set.count(Entity::Workflow), 1);
assert_eq!(set.count(Entity::Channel), 1, "the nested entity must load");
assert_eq!(set.count(Entity::Connector), 0);
assert_eq!(report.skipped.len(), 1, "request.json is not an entity");
assert_eq!(report.unparseable.len(), 1, "broken.json must be reported");
assert!(
report.unparseable[0].0.ends_with("broken.json"),
"a file that does not parse is a likely mistake, not a skip"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_boundary_is_empty_by_default() {
let b = Boundary::default();
assert!(!b.allows_channel("anything"));
assert!(!b.allows_connector("anything"));
assert!(!b.allows_model("anything"));
}
#[test]
fn model_manifests_are_found_by_shape_and_their_artifacts_read() {
use crate::model::fixture;
let dir = std::env::temp_dir().join(format!("orion-defs-models-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("models/c4")).expect("test fixture");
std::fs::create_dir_all(dir.join("elsewhere")).expect("test fixture");
std::fs::write(dir.join("models/c4/model.json"), fixture::MANIFEST).expect("test fixture");
std::fs::write(dir.join("models/c4/c4-tiny.onnx"), fixture::ONNX).expect("test fixture");
std::fs::write(
dir.join("elsewhere/model.json"),
fixture::MANIFEST.replace("ada.c4-tiny", "ada.other"),
)
.expect("test fixture");
std::fs::write(
dir.join("wf.json"),
r#"{"name":"w","workflow_id":"w","tasks":[]}"#,
)
.expect("test fixture");
let (set, report) = DefinitionSet::from_directory(&dir).expect("loads");
assert!(report.findings.is_empty(), "{:?}", report.findings);
assert_eq!(set.count(Entity::Workflow), 1);
assert_eq!(set.models.len(), 2, "both manifests are models, not skips");
assert!(report.skipped.is_empty(), "{:?}", report.skipped);
let c4 = set.model_of("ada.c4-tiny").expect("the fixture");
assert!(
c4.artifact_path
.as_ref()
.is_some_and(|p| p.ends_with("c4-tiny.onnx"))
);
assert_eq!(
c4.digest.as_deref(),
Some(crate::crypto::sha256_digest(fixture::ONNX).as_str())
);
assert_eq!(c4.artifact_bytes, Some(fixture::ONNX.len() as u64));
let graph = c4.graph.as_ref().expect("read").as_ref().expect("a graph");
assert_eq!(graph.parameters, 1479);
let entry = c4.manifest_entry().expect("runnable");
assert_eq!(entry.stats.as_ref().map(|s| s.parameters), Some(1479));
assert_eq!(entry.stats.as_ref().map(|s| s.artifact_bytes), Some(6171));
let other = set.model_of("ada.other").expect("the other");
assert!(other.artifact_path.is_none());
assert!(other.digest.is_none() && other.graph.is_none());
assert!(other.manifest_entry().is_none(), "nothing to run");
let mut by_flag = DefinitionSet::default();
let findings = by_flag
.add_model_dirs(&[dir.display().to_string()])
.expect("walks");
assert!(findings.is_empty(), "{findings:?}");
assert_eq!(by_flag.models.len(), 2);
let findings = by_flag
.add_model_dirs(&[dir.join("models/c4/model.json").display().to_string()])
.expect("walks");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].check, "duplicate.model");
assert_eq!(by_flag.models.len(), 2);
std::fs::write(
dir.join("elsewhere/broken.json"),
r#"{"abi":"orion:model@1.0.0","name":"Bad.Name","inputs":[]}"#,
)
.expect("test fixture");
let (_, report) = DefinitionSet::from_directory(&dir).expect("loads");
assert!(
report.findings.iter().any(|f| f.check == "parse.model"),
"{:?}",
report.findings
);
let _ = std::fs::remove_dir_all(&dir);
}
}