bijux-dag-app 0.4.1

Application orchestration and response-shaping layer for Bijux DAG command surfaces.
Documentation
use crate::fs_input::read_utf8_file;
use bijux_dag_core::{materialize_graph_input_value, Graph, GraphInputSpec, ParamValue};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RuntimeInputBinding {
    pub(crate) bound_inputs: BTreeMap<String, GraphInputSpec>,
    pub(crate) effective_inputs: BTreeMap<String, Value>,
    pub(crate) human_summary: Map<String, Value>,
    pub(crate) redacted_keys: Vec<String>,
}

pub(crate) fn bind_runtime_inputs(
    declared_inputs: &BTreeMap<String, GraphInputSpec>,
    inputs_file: Option<&Path>,
    cli_inputs: &[String],
) -> Result<RuntimeInputBinding, String> {
    let mut bound_inputs = declared_inputs.clone();

    if let Some(path) = inputs_file {
        for (key, value) in parse_inputs_file(path)? {
            bind_runtime_input_value(&key, value, declared_inputs, &mut bound_inputs)?;
        }
    }

    for assignment in cli_inputs {
        let (key, value) = parse_cli_input_assignment(assignment)?;
        bind_runtime_input_value(&key, value, declared_inputs, &mut bound_inputs)?;
    }

    let effective_inputs = effective_inputs_from_specs(&bound_inputs)?;
    let (human_summary, redacted_keys) = redact_input_summary(&effective_inputs);
    Ok(RuntimeInputBinding { bound_inputs, effective_inputs, human_summary, redacted_keys })
}

pub(crate) fn missing_required_graph_inputs(graph: &Graph) -> Vec<String> {
    let mut required = BTreeSet::new();
    for node in &graph.nodes {
        collect_required_graph_inputs(&node.params, &mut required);
    }

    required
        .into_iter()
        .filter(|key| graph.inputs.get(key).and_then(GraphInputSpec::effective_value).is_none())
        .collect()
}

fn parse_inputs_file(path: &Path) -> Result<Map<String, Value>, String> {
    let payload = read_utf8_file(path)
        .map_err(|error| format!("failed to read inputs file {}: {error}", path.display()))?;
    let value: Value = serde_json::from_str(&payload)
        .map_err(|error| format!("inputs file {} is not valid JSON: {error}", path.display()))?;
    match value {
        Value::Object(map) => Ok(map),
        _ => Err(format!(
            "inputs file {} must contain a JSON object at the top level",
            path.display()
        )),
    }
}

fn parse_cli_input_assignment(raw: &str) -> Result<(String, Value), String> {
    let Some((key, value)) = raw.split_once('=') else {
        return Err(format!("input assignment must use key=value: {raw}"));
    };
    let key = key.trim();
    if key.is_empty() {
        return Err("input assignment key must not be empty".to_string());
    }
    Ok((key.to_string(), parse_runtime_input_value(value)))
}

fn bind_runtime_input_value(
    key: &str,
    value: Value,
    declared_inputs: &BTreeMap<String, GraphInputSpec>,
    bound_inputs: &mut BTreeMap<String, GraphInputSpec>,
) -> Result<(), String> {
    let Some(spec) = declared_inputs.get(key) else {
        return Err(format!("runtime input is not declared in graph.inputs: {key}"));
    };
    let normalized = materialize_graph_input_value(spec, &value, &format!("/inputs/{key}"))
        .map_err(|error| format!("runtime input at {}: {}", error.path, error.message))?;
    bound_inputs.insert(key.to_string(), spec.with_effective_value(normalized));
    Ok(())
}

fn parse_runtime_input_value(raw: &str) -> Value {
    serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}

pub(crate) fn is_secret_like_input_key(key: &str) -> bool {
    let key = key.to_ascii_lowercase();
    key.contains("secret")
        || key.contains("token")
        || key.contains("password")
        || key.contains("api_key")
        || key.contains("apikey")
        || key.contains("credential")
}

fn redact_input_summary(inputs: &BTreeMap<String, Value>) -> (Map<String, Value>, Vec<String>) {
    let mut summary = Map::new();
    let mut redacted_keys = Vec::new();
    for (key, value) in inputs {
        if is_secret_like_input_key(key) {
            summary.insert(key.clone(), Value::String("[REDACTED]".to_string()));
            redacted_keys.push(key.clone());
        } else {
            summary.insert(key.clone(), value.clone());
        }
    }
    redacted_keys.sort();
    (summary, redacted_keys)
}

fn effective_inputs_from_specs(
    inputs: &BTreeMap<String, GraphInputSpec>,
) -> Result<BTreeMap<String, Value>, String> {
    let mut effective = BTreeMap::new();
    for (key, spec) in inputs {
        if let Some(value) = spec.effective_value() {
            let materialized =
                materialize_graph_input_value(spec, value, &format!("/inputs/{key}")).map_err(
                    |error| format!("runtime input at {}: {}", error.path, error.message),
                )?;
            effective.insert(key.clone(), materialized);
        }
    }
    Ok(effective)
}

fn collect_required_graph_inputs(value: &ParamValue, required: &mut BTreeSet<String>) {
    match value {
        ParamValue::Ref(reference) => {
            if let Some(input_name) = &reference.graph_input {
                required.insert(input_name.clone());
            }
        }
        ParamValue::Array(items) => {
            for item in items {
                collect_required_graph_inputs(item, required);
            }
        }
        ParamValue::Object(map) => {
            for item in map.values() {
                collect_required_graph_inputs(item, required);
            }
        }
        ParamValue::Literal(_) => {}
    }
}

#[cfg(test)]
mod tests {
    use super::{bind_runtime_inputs, is_secret_like_input_key, missing_required_graph_inputs};
    use bijux_dag_core::{parse_graph_strict, GraphInputSpec};
    use serde_json::json;
    use std::collections::BTreeMap;

    fn declared_inputs() -> BTreeMap<String, GraphInputSpec> {
        serde_json::from_value(json!({
            "region": {"type":"string","default":"eu-west-1"},
            "attempts": {"type":"integer","default":1},
            "api_token": {"type":"string","required":true}
        }))
        .expect("declared inputs")
    }

    #[test]
    fn cli_inputs_override_declared_defaults() {
        let binding = bind_runtime_inputs(
            &declared_inputs(),
            None,
            &["region=us-east-1".to_string(), "attempts=3".to_string()],
        )
        .expect("binding");
        assert_eq!(binding.effective_inputs["region"], "us-east-1");
        assert_eq!(binding.effective_inputs["attempts"], 3);
    }

    #[test]
    fn cli_inputs_accept_json_literals_and_plain_strings() {
        let binding = bind_runtime_inputs(
            &declared_inputs(),
            None,
            &["region=plain-text".to_string(), "attempts=2".to_string()],
        )
        .expect("binding");
        assert_eq!(binding.effective_inputs["region"], "plain-text");
        assert_eq!(binding.effective_inputs["attempts"], 2);
    }

    #[test]
    fn undeclared_runtime_input_is_rejected() {
        let error =
            bind_runtime_inputs(&declared_inputs(), None, &["unknown=x".to_string()]).unwrap_err();
        assert!(error.contains("runtime input is not declared"));
    }

    #[test]
    fn secret_like_keys_are_redacted_in_human_summary() {
        let binding =
            bind_runtime_inputs(&declared_inputs(), None, &["api_token=s3cr3t".to_string()])
                .expect("binding");
        assert_eq!(binding.effective_inputs["api_token"], "s3cr3t");
        assert_eq!(binding.human_summary["api_token"], "[REDACTED]");
        assert_eq!(binding.redacted_keys, vec!["api_token".to_string()]);
        assert!(is_secret_like_input_key("DB_PASSWORD"));
    }

    #[test]
    fn missing_required_graph_inputs_only_flags_referenced_null_values() {
        let graph = parse_graph_strict(
            r#"{
              "spec":"bijux-dag/v0.1",
              "meta":{"name":"wf","owners":[],"tags":[]},
              "inputs":{
                "region":{"type":"string","required":true},
                "api_token":{"type":"string","required":true},
                "unused":{"type":"string","required":true}
              },
              "nodes":[
                {
                  "id":"n1",
                  "kind":"const",
                  "inputs":[],
                  "outputs":[{"name":"value","path":"out.json"}],
                  "params":{
                    "value":{
                      "region":{"graph_input":"region"},
                      "token":{"graph_input":"api_token"}
                    }
                  }
                }
              ],
              "edges":[]
            }"#,
        )
        .expect("graph");

        assert_eq!(
            missing_required_graph_inputs(&graph),
            vec!["api_token".to_string(), "region".to_string()]
        );
    }
}