trellis-core 0.1.2

Deterministic reactive resource graph core types.
Documentation
use std::collections::BTreeSet;

use trellis_core::{
    AuditEvent, DependencyList, Graph, OutputFrameKindTrace, ResourceCommandCause,
    ResourceCommandKind, ResourceKey, ResourcePlan, SetDiff,
};

#[derive(Clone, Debug, Eq, PartialEq)]
enum Command {
    Open(String),
}

fn key(value: &str) -> ResourceKey {
    ResourceKey::new(value.to_owned())
}

fn set(entries: &[&str]) -> BTreeSet<String> {
    entries.iter().map(|value| (*value).to_owned()).collect()
}

fn plan_added(
    ctx: &trellis_core::PlanContext<'_, SetDiff<String>>,
) -> Result<ResourcePlan<Command>, trellis_core::PlanError> {
    let mut plan = ResourcePlan::new();
    for added in &ctx.diff().added {
        plan.open(
            key(&added.value),
            ctx.scope(),
            Command::Open(added.value.clone()),
        );
    }
    Ok(plan)
}

fn plan_added_removed(
    ctx: &trellis_core::PlanContext<'_, SetDiff<String>>,
) -> Result<ResourcePlan<Command>, trellis_core::PlanError> {
    let mut plan = plan_added(ctx)?;
    for removed in &ctx.diff().removed {
        plan.close(key(&removed.value), ctx.scope());
    }
    Ok(plan)
}

#[test]
fn audit_explains_node_resource_and_output_changes() {
    let mut graph = Graph::<Command, BTreeSet<String>>::new_with_command_type();
    let mut tx = graph.begin_transaction().unwrap();
    let scope = tx.create_scope("scope").unwrap();
    let source = tx.input::<BTreeSet<String>>("source").unwrap();
    tx.set_input(source, set(&["a", "b"])).unwrap();
    let collection = tx
        .set_collection(
            "resources",
            DependencyList::new([source.id()]).unwrap(),
            move |ctx| Ok(ctx.input(source)?.clone()),
        )
        .unwrap();
    tx.set_resource_planner(collection, scope, plan_added_removed)
        .unwrap();
    let output = tx
        .materialized_output(
            "output",
            scope,
            DependencyList::new([collection.id()]).unwrap(),
            move |ctx| Ok(ctx.set_collection(collection)?.clone()),
        )
        .unwrap();
    let result = tx.commit().unwrap();
    drop(tx);

    assert_eq!(result.trace().resource_commands.len(), 2);
    assert_eq!(
        graph.dependency_path(source.id(), collection.id()),
        Some(vec![source.id(), collection.id()])
    );

    let changed = graph.why_changed(collection).unwrap();
    assert_eq!(changed.node, collection.id());
    assert_eq!(changed.input_causes, vec![source.id()]);
    assert_eq!(
        changed.dependency_paths,
        vec![vec![source.id(), collection.id()]]
    );

    let resource = graph.why_resource_command(&key("a")).unwrap();
    assert_eq!(resource.scope, scope);
    assert_eq!(resource.kind, ResourceCommandKind::Open);
    assert_eq!(
        resource.cause,
        ResourceCommandCause::Planner {
            collection: collection.id()
        }
    );
    assert_eq!(resource.collection_diffs, vec![collection.id()]);
    assert_eq!(resource.input_causes, vec![source.id()]);

    let frame = graph.why_output_frame(output.key()).unwrap();
    assert_eq!(frame.scope, scope);
    assert_eq!(frame.kind, OutputFrameKindTrace::Baseline);
    assert_eq!(frame.dependencies, vec![collection.id()]);
    assert_eq!(frame.changed_dependencies, vec![collection.id()]);

    assert!(
        graph
            .audit_log()
            .iter()
            .any(|entry| { entry.event == AuditEvent::CollectionChanged(collection.id()) })
    );

    let mut tx = graph.begin_transaction().unwrap();
    tx.set_input(source, set(&["a"])).unwrap();
    tx.commit().unwrap();
    drop(tx);

    let close = graph.why_resource_command(&key("b")).unwrap();
    assert_eq!(close.kind, ResourceCommandKind::Close);
    assert_eq!(
        close.cause,
        ResourceCommandCause::Planner {
            collection: collection.id()
        }
    );
    assert_eq!(close.collection_diffs, vec![collection.id()]);
    assert_eq!(
        close.dependency_paths,
        vec![vec![source.id(), collection.id()]]
    );
}

#[test]
fn scope_resource_inventory_is_deterministic_and_empty_after_close() {
    let mut graph = Graph::<Command>::new_with_command_type();
    let mut tx = graph.begin_transaction().unwrap();
    let scope = tx.create_scope("scope").unwrap();
    let source = tx.input::<BTreeSet<String>>("source").unwrap();
    tx.set_input(source, set(&["b", "a"])).unwrap();
    let collection = tx
        .set_collection(
            "resources",
            DependencyList::new([source.id()]).unwrap(),
            move |ctx| Ok(ctx.input(source)?.clone()),
        )
        .unwrap();
    tx.set_resource_planner(collection, scope, plan_added_removed)
        .unwrap();
    tx.commit().unwrap();
    drop(tx);

    let inventory = graph.scope_resource_inventory(scope).unwrap();
    assert_eq!(inventory.resources, vec![key("a"), key("b")]);

    let mut tx = graph.begin_transaction().unwrap();
    tx.close_scope(scope).unwrap();
    let result = tx.commit().unwrap();
    drop(tx);

    assert_eq!(result.resource_plan.commands().len(), 2);
    assert_eq!(
        graph.why_resource_command(&key("a")).unwrap().cause,
        ResourceCommandCause::ScopeClosed { scope }
    );
    assert!(
        graph
            .scope_resource_inventory(scope)
            .unwrap()
            .resources
            .is_empty()
    );
    assert!(graph.orphan_resources().is_empty());
}

#[test]
fn audit_uses_exact_planner_collection_for_resource_commands() {
    let mut graph = Graph::<Command>::new_with_command_type();
    let mut tx = graph.begin_transaction().unwrap();
    let scope = tx.create_scope("scope").unwrap();
    let first_input = tx.input::<BTreeSet<String>>("first").unwrap();
    let second_input = tx.input::<BTreeSet<String>>("second").unwrap();
    tx.set_input(first_input, set(&["a"])).unwrap();
    tx.set_input(second_input, set(&["b"])).unwrap();
    let first = tx
        .set_collection(
            "first-set",
            DependencyList::new([first_input.id()]).unwrap(),
            move |ctx| Ok(ctx.input(first_input)?.clone()),
        )
        .unwrap();
    let second = tx
        .set_collection(
            "second-set",
            DependencyList::new([second_input.id()]).unwrap(),
            move |ctx| Ok(ctx.input(second_input)?.clone()),
        )
        .unwrap();
    for collection in [first, second] {
        tx.set_resource_planner(collection, scope, plan_added)
            .unwrap();
    }
    tx.commit().unwrap();
    drop(tx);

    assert_eq!(
        graph.why_resource_command(&key("a")).unwrap().cause,
        ResourceCommandCause::Planner {
            collection: first.id()
        }
    );
    assert_eq!(
        graph.why_resource_command(&key("b")).unwrap().input_causes,
        vec![second_input.id()]
    );
}

#[test]
fn late_planner_registration_explains_existing_collection_members() {
    let mut graph = Graph::<Command>::new_with_command_type();
    let mut tx = graph.begin_transaction().unwrap();
    let scope = tx.create_scope("scope").unwrap();
    let source = tx.input::<BTreeSet<String>>("source").unwrap();
    tx.set_input(source, set(&["late"])).unwrap();
    let collection = tx
        .set_collection(
            "resources",
            DependencyList::new([source.id()]).unwrap(),
            move |ctx| Ok(ctx.input(source)?.clone()),
        )
        .unwrap();
    tx.commit().unwrap();
    drop(tx);

    let mut tx = graph.begin_transaction().unwrap();
    tx.set_resource_planner(collection, scope, plan_added)
        .unwrap();
    tx.commit().unwrap();
    drop(tx);

    let explanation = graph.why_resource_command(&key("late")).unwrap();
    assert_eq!(
        explanation.cause,
        ResourceCommandCause::Planner {
            collection: collection.id()
        }
    );
    assert_eq!(explanation.collection_diffs, vec![collection.id()]);
}

#[test]
fn audit_debug_dump_is_deterministic() {
    fn build_dump() -> String {
        let mut graph = Graph::<Command>::new_with_command_type();
        let mut tx = graph.begin_transaction().unwrap();
        let scope = tx.create_scope("scope").unwrap();
        let source = tx.input::<BTreeSet<String>>("source").unwrap();
        tx.set_input(source, set(&["x"])).unwrap();
        let collection = tx
            .set_collection(
                "resources",
                DependencyList::new([source.id()]).unwrap(),
                move |ctx| Ok(ctx.input(source)?.clone()),
            )
            .unwrap();
        tx.set_resource_planner(collection, scope, plan_added)
            .unwrap();
        tx.commit().unwrap();
        drop(tx);
        graph.debug_dump()
    }

    let first = build_dump();
    let second = build_dump();

    assert_eq!(first, second);
    assert!(first.contains("Resources:"));
    assert!(first.contains("Audit:"));
}