effectful 0.3.0

Effect<A, E, R> (sync + async), context/layers, pipe — interpreter-style, no bundled executor
Documentation
use effectful::{LayerError, Service, ServiceContext, TypedLayer};

#[derive(Clone, Service)]
struct Config {
  _port: u16,
}

#[derive(Clone, Service)]
#[allow(dead_code)]
struct Queue {
  _name: String,
}

mod build_with_dependencies {
  use super::*;

  mod when_requirement_unmet {
    use super::*;

    #[test]
    fn returns_stable_missing_dependencies() {
      let layer = TypedLayer::from_fn(|| Ok(42))
        .requiring("Queue")
        .requiring("Config");

      let ctx = ServiceContext::empty();
      let result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

      assert_eq!(
        result,
        Err(LayerError::MissingDependencies {
          missing: vec!["Config".to_string(), "Queue".to_string()],
        })
      );
    }
  }

  mod when_requirement_partially_unmet {
    use super::*;

    #[test]
    fn returns_only_missing_requirements() {
      let layer = TypedLayer::from_fn(|| Ok(42))
        .requiring("Queue")
        .requiring("Config");

      let ctx = ServiceContext::empty().add(Config { _port: 8080 });
      let result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

      assert_eq!(
        result,
        Err(LayerError::MissingDependencies {
          missing: vec!["Queue".to_string()],
        })
      );
    }
  }

  mod when_requirement_present {
    use super::*;

    #[test]
    fn builds_layer() {
      let layer = TypedLayer::from_fn(|| Ok(42)).requiring("Config");

      let ctx = ServiceContext::empty().add(Config { _port: 8080 });
      let result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

      assert_eq!(result, Ok(42));
    }

    #[test]
    fn builds_layer_with_multiple_requirements_all_met() {
      let layer = TypedLayer::from_fn(|| Ok(42))
        .requiring("Queue")
        .requiring("Config");

      let ctx = ServiceContext::empty()
        .add(Config { _port: 8080 })
        .add(Queue {
          _name: "test".to_string(),
        });
      let result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

      assert_eq!(result, Ok(42));
    }
  }

  mod when_no_requirements {
    use super::*;

    #[test]
    fn builds_layer_with_empty_context() {
      let layer = TypedLayer::from_fn(|| Ok(42));

      let ctx = ServiceContext::empty();
      let result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

      assert_eq!(result, Ok(42));
    }
  }
}

mod build_with_dependencies_custom_error {
  use super::*;
  use std::fmt;

  #[derive(Clone, Debug, PartialEq)]
  enum CustomError {
    LayerMissing(LayerError),
    Other(&'static str),
  }

  impl From<LayerError> for CustomError {
    fn from(e: LayerError) -> Self {
      CustomError::LayerMissing(e)
    }
  }

  impl fmt::Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
      match self {
        CustomError::LayerMissing(e) => write!(f, "{e}"),
        CustomError::Other(msg) => write!(f, "{msg}"),
      }
    }
  }

  impl std::error::Error for CustomError {}

  #[test]
  fn returns_custom_error_on_missing_dependency() {
    let layer = TypedLayer::from_fn(|| Ok::<i32, CustomError>(42)).requiring("Config");

    let ctx = ServiceContext::empty();
    let result: Result<i32, CustomError> = layer.build_with_dependencies(&ctx);

    match result {
      Err(CustomError::LayerMissing(LayerError::MissingDependencies { missing })) => {
        assert_eq!(missing, vec!["Config".to_string()]);
      }
      other => panic!("expected CustomError::LayerMissing, got {other:?}"),
    }
  }

  #[test]
  fn preserves_build_error_when_requirements_met() {
    let layer = TypedLayer::from_fn(|| Err::<i32, CustomError>(CustomError::Other("build failed")))
      .requiring("Config");

    let ctx = ServiceContext::empty().add(Config { _port: 8080 });
    let result: Result<i32, CustomError> = layer.build_with_dependencies(&ctx);

    assert_eq!(result, Err(CustomError::Other("build failed")));
  }
}

mod build_without_dependency_validation {
  use super::*;

  #[test]
  fn remains_compatible() {
    let layer: TypedLayer<i32, LayerError> = TypedLayer::from_fn(|| Ok(42));

    let result = layer.build();

    assert_eq!(result, Ok(42));
  }
}

mod to_diagnostic {
  use super::*;

  #[test]
  fn missing_dependencies_uses_stable_service_keys() {
    let err = LayerError::MissingDependencies {
      missing: vec!["Queue".to_string(), "Config".to_string()],
    };

    let diagnostic = err.to_diagnostic();

    assert_eq!(diagnostic.code, "missing-provider");
    assert!(diagnostic.message.contains("`Config`"));
    assert!(diagnostic.message.contains("`Queue`"));
    assert!(diagnostic.suggestion.contains("provider"));
  }

  #[test]
  fn single_missing_dependency_uses_single_provider_diagnostic() {
    let err = LayerError::MissingDependencies {
      missing: vec!["Config".to_string()],
    };

    let diagnostic = err.to_diagnostic();

    assert_eq!(diagnostic.code, "missing-provider");
    assert!(diagnostic.message.contains("requires service `Config`"));
  }
}

mod planner_consistency {
  use super::*;
  use effectful::{LayerGraph, LayerNode};

  #[test]
  fn typed_layer_and_direct_layer_graph_produce_matching_missing_service_order() {
    let layer = TypedLayer::from_fn(|| Ok(42))
      .requiring("Queue")
      .requiring("Config");

    let ctx = ServiceContext::empty();
    let typed_result: Result<i32, LayerError> = layer.build_with_dependencies(&ctx);

    let graph = LayerGraph::new([LayerNode::new(
      "typed",
      ["Queue", "Config"],
      Vec::<&str>::new(),
    )]);
    let graph_err = graph.plan_topological().expect_err("should fail");

    let typed_missing = match typed_result {
      Err(LayerError::MissingDependencies { missing }) => missing,
      Ok(_) => panic!("expected missing dependencies"),
    };

    let graph_missing = match graph_err {
      effectful::LayerPlannerError::MissingProviders { missing } => {
        missing.into_iter().map(|m| m.service).collect::<Vec<_>>()
      }
      other => panic!("expected MissingProviders, got {other:?}"),
    };

    assert_eq!(typed_missing, graph_missing);
  }
}