use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value};
use super::finding::Finding;
const SHARED_KEYS: [&str; 3] = ["constants", "errors", "fragments"];
#[derive(Debug, Clone, Default)]
pub struct Fragment {
pub params: BTreeMap<String, Option<Value>>,
pub tasks: Vec<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct SharedDefinitions {
pub namespaces: BTreeMap<String, BTreeMap<String, Value>>,
pub fragments: BTreeMap<String, Fragment>,
}
impl SharedDefinitions {
pub fn from_directory(dir: &std::path::Path) -> Result<(Self, Vec<Finding>), String> {
let mut shared = SharedDefinitions::default();
let mut findings = Vec::new();
let mut docs: Vec<(String, Value)> = Vec::new();
collect(dir, &mut docs, &mut findings)?;
docs.sort_by(|a, b| a.0.cmp(&b.0));
for (origin, doc) in &docs {
shared.merge(doc, origin, &mut findings);
}
Ok((shared, findings))
}
pub fn is_empty(&self) -> bool {
self.namespaces.is_empty() && self.fragments.is_empty()
}
pub fn is_shared_document(doc: &Value) -> bool {
let Some(obj) = doc.as_object() else {
return false;
};
super::Entity::classify(doc).is_none() && SHARED_KEYS.iter().any(|k| obj.contains_key(*k))
}
pub fn merge(&mut self, doc: &Value, origin: &str, findings: &mut Vec<Finding>) {
let Some(obj) = doc.as_object() else {
return;
};
for (key, value) in obj {
if key == "fragments" {
self.merge_fragments(value, origin, findings);
continue;
}
let Some(entries) = value.as_object() else {
findings.push(Finding::error(
"shared.namespace",
origin,
format!("'{key}' must be an object of named values"),
));
continue;
};
let ns = self.namespaces.entry(key.clone()).or_default();
for (name, val) in entries {
if ns.contains_key(name) {
findings.push(Finding::error(
"shared.duplicate",
origin,
format!("'{key}.{name}' is already defined elsewhere in the set"),
));
continue;
}
ns.insert(name.clone(), val.clone());
}
}
}
fn merge_fragments(&mut self, value: &Value, origin: &str, findings: &mut Vec<Finding>) {
let Some(entries) = value.as_object() else {
findings.push(Finding::error(
"shared.namespace",
origin,
"'fragments' must be an object of named task sequences",
));
return;
};
for (name, spec) in entries {
if self.fragments.contains_key(name) {
findings.push(Finding::error(
"shared.duplicate",
origin,
format!("fragment '{name}' is already defined elsewhere in the set"),
));
continue;
}
let Some(tasks) = spec.get("tasks").and_then(Value::as_array) else {
findings.push(Finding::error(
"shared.fragment",
origin,
format!("fragment '{name}' has no 'tasks' array"),
));
continue;
};
let mut params = BTreeMap::new();
if let Some(declared) = spec.get("params").and_then(Value::as_object) {
for (param, decl) in declared {
params.insert(param.clone(), decl.get("default").cloned());
}
}
self.fragments.insert(
name.clone(),
Fragment {
params,
tasks: tasks.clone(),
},
);
}
}
pub fn expand(&self, doc: &mut Value, origin: &str, findings: &mut Vec<Finding>) {
super::compile::compile(
doc,
&super::compile::Cx {
shared: self,
origin,
},
findings,
);
}
pub(super) fn expand_tasks(
&self,
tasks: &[Value],
origin: &str,
findings: &mut Vec<Finding>,
) -> Vec<Value> {
let mut out = Vec::with_capacity(tasks.len());
for task in tasks {
let Some(name) = task.get("use").and_then(Value::as_str) else {
if crate::engine::is_group(task) {
let mut group = task.clone();
if let Some(inner) = task.get("tasks").and_then(Value::as_array) {
group["tasks"] = Value::Array(self.expand_tasks(inner, origin, findings));
}
out.push(group);
continue;
}
out.push(task.clone());
continue;
};
let instance = task.get("id").and_then(Value::as_str).unwrap_or(name);
let Some(fragment) = self.fragments.get(name) else {
findings.push(Finding::error(
"closure.fragment",
format!("{origin} task '{instance}'"),
format!("fragment '{name}' is not defined in the set"),
));
continue;
};
let args = self.fragment_args(fragment, task, name, instance, origin, findings);
for inner in &fragment.tasks {
let mut expanded = inner.clone();
substitute_params(&mut expanded, &args);
if namespace_fragment_step(&mut expanded, instance, name, findings) {
out.push(expanded);
}
}
}
out
}
fn fragment_args(
&self,
fragment: &Fragment,
task: &Value,
name: &str,
instance: &str,
origin: &str,
findings: &mut Vec<Finding>,
) -> BTreeMap<String, Value> {
let supplied = task
.get("with")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let declared: BTreeSet<&String> = fragment.params.keys().collect();
for key in supplied.keys() {
if !declared.contains(key) {
findings.push(Finding::error(
"shared.fragment_param",
format!("{origin} task '{instance}'"),
format!("fragment '{name}' declares no parameter '{key}'"),
));
}
}
let mut args = BTreeMap::new();
for (param, default) in &fragment.params {
match supplied.get(param).or(default.as_ref()) {
Some(value) => {
args.insert(param.clone(), value.clone());
}
None => findings.push(Finding::error(
"shared.fragment_param",
format!("{origin} task '{instance}'"),
format!("fragment '{name}' requires parameter '{param}', which has no default"),
)),
}
}
args
}
pub(super) fn splice(&self, value: &mut Value, origin: &str, findings: &mut Vec<Finding>) {
match value {
Value::Array(items) => {
for item in items {
self.splice(item, origin, findings);
}
}
Value::Object(map) => {
for v in map.values_mut() {
self.splice(v, origin, findings);
}
let Some(path) = map.get("$from").and_then(Value::as_str).map(str::to_string)
else {
return;
};
let replacement = match self.lookup(&path) {
Some(target) => apply_splice(map, target),
None => {
findings.push(Finding::error(
"closure.shared_value",
origin,
format!("'{path}' is not defined in the set"),
));
map.remove("$from");
None
}
};
if let Some(replacement) = replacement {
*value = replacement;
}
}
_ => {}
}
}
fn lookup(&self, path: &str) -> Option<&Value> {
let (namespace, key) = path.split_once('.')?;
self.namespaces.get(namespace)?.get(key)
}
}
pub fn first_reference(doc: &Value) -> Option<String> {
super::compile::residue(doc, "")
.first()
.map(super::compile::Residue::describe)
}
fn collect(
dir: &std::path::Path,
out: &mut Vec<(String, Value)>,
findings: &mut Vec<Finding>,
) -> Result<(), String> {
super::set::walk_json_files(dir, &mut |path, parsed| match parsed {
Ok(doc) => {
if SharedDefinitions::is_shared_document(&doc) {
out.push((path.display().to_string(), doc));
}
}
Err(e) => findings.push(Finding::warning(
"shared.unparseable",
path.display().to_string(),
format!(
"could not be read as JSON ({e}), so any shared value or fragment \
it declares is missing from this catalog"
),
)),
})
}
fn apply_splice(map: &mut Map<String, Value>, target: &Value) -> Option<Value> {
map.remove("$from");
match target {
Value::Object(fields) => {
for (key, value) in fields {
map.entry(key.clone()).or_insert_with(|| value.clone());
}
None
}
other if map.is_empty() => Some(other.clone()),
_ => None,
}
}
fn namespace_fragment_step(
step: &mut Value,
instance: &str,
fragment: &str,
findings: &mut Vec<Finding>,
) -> bool {
if step.get("use").is_some() {
findings.push(Finding::error(
"shared.fragment_nested",
format!("fragment '{fragment}'"),
"a fragment cannot include another fragment",
));
return false;
}
if let Some(id) = step.get("id").and_then(Value::as_str) {
step["id"] = Value::String(format!("{instance}.{id}"));
}
if crate::engine::is_group(step)
&& let Some(members) = step.get_mut("tasks").and_then(Value::as_array_mut)
{
members.retain_mut(|member| namespace_fragment_step(member, instance, fragment, findings));
}
true
}
fn substitute_params(value: &mut Value, args: &BTreeMap<String, Value>) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| substitute_params(v, args)),
Value::Object(map) => {
if map.len() == 1
&& let Some(name) = map.get("$param").and_then(Value::as_str)
&& let Some(arg) = args.get(name)
{
*value = arg.clone();
return;
}
map.values_mut().for_each(|v| substitute_params(v, args));
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn shared() -> (SharedDefinitions, Vec<Finding>) {
let mut s = SharedDefinitions::default();
let mut f = Vec::new();
s.merge(
&json!({
"constants": { "db": { "connector": "sias-mongo", "database": "app" },
"timeout": 30000 },
"errors": { "USER_NOT_FOUND": { "status": 400, "body": "User Not Found !" } },
"fragments": { "require-session": {
"params": { "deny_message": { "default": "Session expired." },
"realm": {} },
"tasks": [
{ "id": "check", "name": "Check",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.msg", "logic": { "$param": "deny_message" } },
{ "path": "data.realm", "logic": { "$param": "realm" } } ] } } },
{ "id": "refused", "condition": true, "tasks": [
{ "id": "deny", "name": "Deny",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.denied", "logic": { "$param": "realm" } } ] } } } ] },
{ "id": "halt", "name": "Halt",
"function": { "name": "map", "input": { "mappings": [] } } }
] } }
}),
"common.json",
&mut f,
);
(s, f)
}
#[test]
fn a_from_reference_splices_fields_into_its_object() {
let (s, mut f) = shared();
let mut doc = json!({ "input": { "$from": "constants.db", "collection": "users" } });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(
doc["input"],
json!({ "connector": "sias-mongo", "database": "app", "collection": "users" })
);
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn a_sibling_key_overrides_the_shared_value() {
let (s, mut f) = shared();
let mut doc = json!({ "input": { "$from": "constants.db", "database": "other" } });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(doc["input"]["database"], "other");
assert_eq!(doc["input"]["connector"], "sias-mongo");
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn a_lone_reference_to_a_scalar_becomes_that_scalar() {
let (s, mut f) = shared();
let mut doc = json!({ "timeout_ms": { "$from": "constants.timeout" } });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(doc["timeout_ms"], 30000);
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn an_error_catalog_entry_expands_to_its_fields() {
let (s, mut f) = shared();
let mut doc = json!({ "input": { "$from": "errors.USER_NOT_FOUND" } });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(doc["input"]["body"], "User Not Found !");
assert_eq!(doc["input"]["status"], 400);
}
#[test]
fn an_unresolvable_reference_is_reported() {
let (s, mut f) = shared();
let mut doc = json!({ "input": { "$from": "constants.nope" } });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].check, "closure.shared_value");
assert!(f[0].message.contains("constants.nope"), "{:?}", f[0]);
}
#[test]
fn a_fragment_expands_with_namespaced_ids_and_arguments() {
let (s, mut f) = shared();
let mut doc = json!({ "name": "w", "tasks": [
{ "id": "_session", "use": "require-session",
"with": { "deny_message": "Please sign in again.", "realm": "app" } },
{ "id": "own", "name": "Own", "function": { "name": "map", "input": {"mappings": []} } }
] });
s.expand(&mut doc, "wf.json", &mut f);
assert!(f.is_empty(), "{f:?}");
let tasks = doc["tasks"].as_array().expect("array");
assert_eq!(
tasks.len(),
4,
"three fragment steps plus the workflow's own"
);
assert_eq!(tasks[0]["id"], "_session.check", "ids are namespaced");
assert_eq!(
tasks[1]["id"], "_session.refused",
"including a group's own id"
);
assert_eq!(
tasks[1]["tasks"][0]["id"], "_session.deny",
"and the ids inside that group, which is what #294 was"
);
assert_eq!(tasks[2]["id"], "_session.halt");
assert_eq!(
tasks[3]["id"], "own",
"the workflow's own task is untouched"
);
assert_eq!(
tasks[0]["function"]["input"]["mappings"][0]["logic"], "Please sign in again.",
"the call site's argument wins over the default"
);
assert_eq!(tasks[0]["function"]["input"]["mappings"][1]["logic"], "app");
assert_eq!(
tasks[1]["tasks"][0]["function"]["input"]["mappings"][0]["logic"], "app",
"parameters reach a nested task too"
);
}
#[test]
fn two_instances_of_one_fragment_do_not_collide() {
let (s, mut f) = shared();
let mut doc = json!({ "tasks": [
{ "id": "a", "use": "require-session", "with": { "realm": "x" } },
{ "id": "b", "use": "require-session", "with": { "realm": "y" } }
] });
s.expand(&mut doc, "wf.json", &mut f);
let tasks = doc["tasks"].as_array().expect("array");
let ids: Vec<&str> = tasks.iter().filter_map(|t| t["id"].as_str()).collect();
assert_eq!(
ids,
[
"a.check",
"a.refused",
"a.halt",
"b.check",
"b.refused",
"b.halt"
]
);
assert_eq!(tasks[1]["tasks"][0]["id"], "a.deny");
assert_eq!(tasks[4]["tasks"][0]["id"], "b.deny");
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn a_fragment_including_a_fragment_inside_a_group_is_refused() {
let mut s = SharedDefinitions::default();
let mut f = Vec::new();
s.merge(
&json!({ "fragments": {
"outer": { "tasks": [
{ "id": "span", "condition": true, "tasks": [
{ "id": "i", "use": "inner" }] }] },
"inner": { "tasks": [{ "id": "t", "name": "t",
"function": { "name": "map", "input": { "mappings": [] } } }] } } }),
"common.json",
&mut f,
);
let mut doc = json!({ "tasks": [{ "id": "o", "use": "outer" }] });
s.expand(&mut doc, "wf.json", &mut f);
assert!(
f.iter().any(|x| x.check == "shared.fragment_nested"),
"the restriction must be reported where it bites, not left to \
surface as an uncompiled reference the set can actually resolve: {f:?}"
);
assert_eq!(doc["tasks"][0]["tasks"].as_array().map(Vec::len), Some(0));
}
#[test]
fn a_required_parameter_must_be_supplied() {
let (s, mut f) = shared();
let mut doc = json!({ "tasks": [{ "id": "x", "use": "require-session" }] });
s.expand(&mut doc, "wf.json", &mut f);
assert_eq!(f.len(), 1, "{f:?}");
assert!(f[0].message.contains("'realm'"), "{:?}", f[0]);
assert!(f[0].message.contains("no default"), "{:?}", f[0]);
}
#[test]
fn an_unknown_argument_and_an_unknown_fragment_are_reported() {
let (s, mut f) = shared();
let mut doc = json!({ "tasks": [
{ "id": "x", "use": "require-session", "with": { "realm": "r", "typo": 1 } },
{ "id": "y", "use": "no-such-fragment" }
] });
s.expand(&mut doc, "wf.json", &mut f);
let checks: Vec<&str> = f.iter().map(|x| x.check).collect();
assert!(checks.contains(&"shared.fragment_param"), "{f:?}");
assert!(checks.contains(&"closure.fragment"), "{f:?}");
}
#[test]
fn a_name_defined_twice_is_reported() {
let (mut s, mut f) = shared();
s.merge(
&json!({ "constants": { "db": { "connector": "other" } } }),
"second.json",
&mut f,
);
assert_eq!(f.len(), 1, "{f:?}");
assert_eq!(f[0].check, "shared.duplicate");
assert_eq!(
s.namespaces["constants"]["db"]["connector"], "sias-mongo",
"the first definition stands rather than being silently replaced"
);
}
#[test]
fn a_shared_document_is_not_an_entity() {
assert!(SharedDefinitions::is_shared_document(
&json!({"constants": {}})
));
assert!(SharedDefinitions::is_shared_document(
&json!({"fragments": {}})
));
assert!(!SharedDefinitions::is_shared_document(
&json!({"name": "w", "tasks": []})
));
assert!(!SharedDefinitions::is_shared_document(&json!({"data": {}})));
}
#[test]
fn a_fragment_including_a_fragment_is_refused() {
let mut s = SharedDefinitions::default();
let mut f = Vec::new();
s.merge(
&json!({ "fragments": {
"outer": { "tasks": [{ "id": "i", "use": "inner" }] },
"inner": { "tasks": [{ "id": "t", "name": "t",
"function": { "name": "map", "input": { "mappings": [] } } }] } } }),
"common.json",
&mut f,
);
let mut doc = json!({ "tasks": [{ "id": "o", "use": "outer" }] });
s.expand(&mut doc, "wf.json", &mut f);
assert!(
f.iter().any(|x| x.check == "shared.fragment_nested"),
"{f:?}"
);
}
}