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>,
}
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)
}
}
#[derive(Debug, Clone)]
pub struct Definition {
pub entity: Entity,
pub origin: String,
pub doc: Value,
}
#[derive(Debug, Default, Clone)]
pub struct DefinitionSet {
pub definitions: Vec<Definition>,
}
#[derive(Debug, Default)]
pub struct LoadReport {
pub findings: Vec<super::finding::Finding>,
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 from_entries(entries: impl IntoIterator<Item = (Entity, String, Value)>) -> Self {
Self {
definitions: entries
.into_iter()
.map(|(entity, origin, doc)| Definition {
entity,
origin,
doc,
})
.collect(),
}
}
pub fn from_directory(dir: &Path) -> Result<(Self, LoadReport), String> {
Self::from_directory_with(dir, &super::SharedDefinitions::default())
}
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>),
) -> 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;
}
if path.is_dir() {
walk_json_files(&path, visit)?;
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let parsed = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|raw| serde_json::from_str::<Value>(&raw).map_err(|e| e.to_string()));
visit(path, parsed);
}
Ok(())
}
fn walk(
dir: &Path,
set: &mut DefinitionSet,
report: &mut LoadReport,
shared_docs: &mut Vec<(String, Value)>,
) -> Result<(), String> {
walk_json_files(dir, &mut |path, parsed| {
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,
}),
None if super::SharedDefinitions::is_shared_document(&doc) => {
shared_docs.push((path.display().to_string(), doc));
}
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"));
}
}