bijux-dag-core 0.4.1

Deterministic DAG kernel for graph parsing, validation, canonicalization, planning, and identity.
Documentation
use crate::{
    env_allowlist_pattern_is_exact, Effect, Graph, Node, OutputKind, OutputSpec, ParamValue,
    RefSpec,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeInterfaceContract {
    pub declared_inputs: Vec<String>,
    pub declared_outputs: Vec<OutputSpec>,
    pub declared_params: Vec<String>,
    pub declared_effects: Vec<Effect>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeGroupContract {
    pub id: String,
    pub labels: BTreeMap<String, String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypedNode {
    pub node: Node,
    pub interface: NodeInterfaceContract,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum NodeInputSource {
    UpstreamOutput { node_id: String, output_name: String },
    Unbound,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeInputBinding {
    pub name: String,
    pub source: NodeInputSource,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ParamBindingSource {
    GraphInput { input_name: String },
    NodeOutput { node_id: String, output_name: String },
    PathVariable { name: String, relative_path: Option<String> },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeParamBinding {
    pub key_path: String,
    pub source: ParamBindingSource,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeEnvBinding {
    pub name: String,
    pub required: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeOutputContract {
    pub name: String,
    pub path: String,
    pub kind: OutputKind,
    pub required: bool,
    pub media_type: String,
    pub promotable: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeIoContract {
    pub inputs: Vec<NodeInputBinding>,
    pub param_bindings: Vec<NodeParamBinding>,
    pub env_bindings: Vec<NodeEnvBinding>,
    pub outputs: Vec<NodeOutputContract>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeTypeRegistry {
    pub known_types: Vec<String>,
}

impl NodeTypeRegistry {
    pub fn default_registry() -> Self {
        Self {
            known_types: vec![
                "const".to_string(),
                "shell".to_string(),
                "python".to_string(),
                "http".to_string(),
                "file_transform".to_string(),
                "container".to_string(),
            ],
        }
    }

    pub fn validate_node_kinds(&self, nodes: &[Node]) -> Result<(), String> {
        for node in nodes {
            let kind = node.kind.as_str();
            if self.known_types.iter().any(|known| known == kind) {
                continue;
            }
            if kind.is_empty() {
                return Err("node kind must not be empty".to_string());
            }
        }
        Ok(())
    }
}

pub fn derive_interface(node: &Node) -> NodeInterfaceContract {
    let declared_params = match &node.params {
        ParamValue::Object(map) => map.keys().cloned().collect(),
        _ => Vec::new(),
    };
    NodeInterfaceContract {
        declared_inputs: node.inputs.clone(),
        declared_outputs: node.outputs.clone(),
        declared_params,
        declared_effects: node.effects.clone(),
    }
}

pub fn node_input_bindings(graph: &Graph, node_id: &str) -> Vec<NodeInputBinding> {
    let Some(node) = graph.nodes.iter().find(|node| node.id == node_id) else {
        return Vec::new();
    };
    let mut bindings = node
        .inputs
        .iter()
        .map(|name| NodeInputBinding { name: name.clone(), source: NodeInputSource::Unbound })
        .collect::<Vec<_>>();

    for binding in &mut bindings {
        if let Some(edge) = graph
            .edges
            .iter()
            .find(|edge| edge.to.node_id == node_id && edge.to.port == binding.name)
        {
            binding.source = NodeInputSource::UpstreamOutput {
                node_id: edge.from.node_id.clone(),
                output_name: edge.from.port.clone(),
            };
        }
    }

    bindings
}

pub fn node_io_contract(graph: &Graph, node_id: &str) -> Option<NodeIoContract> {
    let node = graph.nodes.iter().find(|node| node.id == node_id)?;
    Some(NodeIoContract {
        inputs: node_input_bindings(graph, node_id),
        param_bindings: collect_param_bindings(&node.params),
        env_bindings: node
            .env_allowlist
            .iter()
            .map(|name| NodeEnvBinding {
                name: name.clone(),
                required: env_allowlist_pattern_is_exact(name),
            })
            .collect(),
        outputs: node
            .outputs
            .iter()
            .map(|output| NodeOutputContract {
                name: output.name.clone(),
                path: output.path.clone(),
                kind: output.kind.clone(),
                required: output.required,
                media_type: output.effective_media_type(),
                promotable: output.promotable,
            })
            .collect(),
    })
}

fn collect_param_bindings(value: &ParamValue) -> Vec<NodeParamBinding> {
    let mut out = Vec::new();
    collect_param_bindings_inner(value, "$", &mut out);
    out
}

fn collect_param_bindings_inner(
    value: &ParamValue,
    key_path: &str,
    out: &mut Vec<NodeParamBinding>,
) {
    match value {
        ParamValue::Ref(reference) => {
            if let Some(binding) = param_binding_from_ref(key_path, reference) {
                out.push(binding);
            }
        }
        ParamValue::Array(items) => {
            for (index, item) in items.iter().enumerate() {
                collect_param_bindings_inner(item, &format!("{key_path}[{index}]"), out);
            }
        }
        ParamValue::Object(map) => {
            for (key, item) in map {
                collect_param_bindings_inner(item, &format!("{key_path}.{key}"), out);
            }
        }
        ParamValue::Literal(_) => {}
    }
}

fn param_binding_from_ref(key_path: &str, reference: &RefSpec) -> Option<NodeParamBinding> {
    if let Some(input_name) = &reference.graph_input {
        return Some(NodeParamBinding {
            key_path: key_path.to_string(),
            source: ParamBindingSource::GraphInput { input_name: input_name.clone() },
        });
    }
    if let Some(node_output) = &reference.node_output {
        return Some(NodeParamBinding {
            key_path: key_path.to_string(),
            source: ParamBindingSource::NodeOutput {
                node_id: node_output.node_id.clone(),
                output_name: node_output.output_name.clone(),
            },
        });
    }
    reference.path_var.as_ref().map(|path_var| NodeParamBinding {
        key_path: key_path.to_string(),
        source: ParamBindingSource::PathVariable {
            name: path_var.name().to_string(),
            relative_path: path_var.relative_path().map(str::to_string),
        },
    })
}

#[cfg(test)]
mod tests {
    use super::node_io_contract;
    use crate::{Graph, NodeOutputContract};
    use serde_json::json;

    #[test]
    fn node_io_contract_preserves_promotable_output_declarations() {
        let graph: Graph = serde_json::from_value(json!({
            "spec": "bijux-dag/v0.1",
            "nodes": [
                {
                    "id": "publish",
                    "kind": "const",
                    "outputs": [
                        {
                            "name": "report",
                            "path": "publish/report.json",
                            "promotable": true
                        }
                    ],
                    "params": {"value": "ok"}
                }
            ],
            "edges": []
        }))
        .expect("graph");

        let contract = node_io_contract(&graph, "publish").expect("io contract");
        assert_eq!(
            contract.outputs,
            vec![NodeOutputContract {
                name: "report".to_string(),
                path: "publish/report.json".to_string(),
                kind: crate::OutputKind::File,
                required: true,
                media_type: "application/octet-stream".to_string(),
                promotable: true,
            }]
        );
    }
}