use std::any::Any;
use std::collections::{BTreeSet, HashMap};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PhaseError {
#[error("cycle detected in pipeline DAG involving phases: [{0}]")]
Cycle(String),
#[error("phase `{phase}` declares missing dependency `{dep}`")]
MissingDependency {
phase: &'static str,
dep: &'static str,
},
#[error("duplicate phase registration: `{0}`")]
DuplicatePhase(&'static str),
#[error("missing input for phase `{0}` — insert it into PipelineCtx before running")]
MissingInput(&'static str),
#[error("type mismatch for value keyed under `{0}`")]
TypeMismatch(&'static str),
#[error("phase `{phase}` failed: {inner}")]
ExecutionFailed {
phase: &'static str,
inner: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
pub struct PipelineCtx {
values: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
}
impl PipelineCtx {
#[must_use]
pub fn new() -> Self {
Self {
values: HashMap::new(),
}
}
pub fn insert<T>(&mut self, name: &'static str, value: T)
where
T: Any + Send + Sync,
{
self.values.insert(name, Box::new(value));
}
pub fn get<T>(&self, name: &str) -> Option<&T>
where
T: Any + Send + Sync,
{
self.values.get(name).and_then(|v| v.downcast_ref::<T>())
}
pub fn remove<T>(&mut self, name: &str) -> Option<T>
where
T: Any + Send + Sync,
{
if !self.values.get(name).is_some_and(|v| v.is::<T>()) {
return None;
}
self.values
.remove(name)
.and_then(|v| v.downcast::<T>().ok().map(|b| *b))
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.values.contains_key(name)
}
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
impl Default for PipelineCtx {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for PipelineCtx {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PipelineCtx")
.field("keys", &self.values.keys().collect::<Vec<_>>())
.finish()
}
}
pub trait Phase: Send + Sync + 'static {
type Input: Any + Send + Sync + 'static;
type Output: Any + Send + Sync + 'static;
const NAME: &'static str;
fn deps() -> &'static [&'static str];
fn run(&self, input: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError>;
}
trait ErasedPhase: Send + Sync + 'static {
fn deps(&self) -> &'static [&'static str];
fn execute(&self, ctx: &mut PipelineCtx) -> Result<(), PhaseError>;
}
impl<P> ErasedPhase for P
where
P: Phase,
{
fn deps(&self) -> &'static [&'static str] {
P::deps()
}
fn execute(&self, ctx: &mut PipelineCtx) -> Result<(), PhaseError> {
let input = ctx
.remove::<P::Input>(P::NAME)
.ok_or(PhaseError::MissingInput(P::NAME))?;
let output = self.run(input, ctx)?;
ctx.insert(P::NAME, output);
Ok(())
}
}
pub struct Pipeline {
phases: HashMap<&'static str, Box<dyn ErasedPhase>>,
}
impl Pipeline {
#[must_use]
pub fn new() -> Self {
Self {
phases: HashMap::new(),
}
}
pub fn register<P>(&mut self, phase: P) -> Result<(), PhaseError>
where
P: Phase,
{
if self.phases.contains_key(P::NAME) {
return Err(PhaseError::DuplicatePhase(P::NAME));
}
self.phases.insert(P::NAME, Box::new(phase));
Ok(())
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.phases.contains_key(name)
}
#[must_use]
pub fn len(&self) -> usize {
self.phases.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.phases.is_empty()
}
fn topo_sort(&self) -> Result<Vec<&'static str>, PhaseError> {
for (&name, phase) in &self.phases {
for &dep in phase.deps() {
if !self.phases.contains_key(dep) {
return Err(PhaseError::MissingDependency { phase: name, dep });
}
}
}
let mut in_degree: HashMap<&'static str, usize> = HashMap::new();
for &name in self.phases.keys() {
in_degree.insert(name, self.phases[&name].deps().len());
}
let mut ready: BTreeSet<&'static str> = self
.phases
.keys()
.filter(|&&n| in_degree[&n] == 0)
.copied()
.collect();
let mut order: Vec<&'static str> = Vec::with_capacity(self.phases.len());
while let Some(&name) = ready.iter().next() {
ready.remove(&name);
order.push(name);
for (&other, phase) in &self.phases {
if phase.deps().contains(&name) {
let entry = in_degree
.get_mut(&other)
.expect("in_degree populated for all");
*entry -= 1;
if *entry == 0 {
ready.insert(other);
}
}
}
}
if order.len() != self.phases.len() {
let cyclic: Vec<&'static str> = self
.phases
.keys()
.filter(|n| !order.contains(n))
.copied()
.collect();
return Err(PhaseError::Cycle(cyclic.join(", ")));
}
Ok(order)
}
pub fn run(&self, ctx: &mut PipelineCtx) -> Result<(), PhaseError> {
let order = self.topo_sort()?;
for &name in &order {
let phase = self
.phases
.get(name)
.expect("topo_sort only returns registered phase names");
phase.execute(ctx).map_err(|e| match e {
PhaseError::ExecutionFailed { .. } => e,
PhaseError::MissingInput(_) => PhaseError::MissingInput(name),
other => PhaseError::ExecutionFailed {
phase: name,
inner: Box::new(other),
},
})?;
}
Ok(())
}
}
impl Default for Pipeline {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Pipeline {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Pipeline")
.field("phases", &self.phases.keys().collect::<Vec<_>>())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! named_phase {
($type_name:ident, $name:expr, $deps:expr) => {
#[derive(Default)]
struct $type_name {
log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl Phase for $type_name {
type Input = String;
type Output = String;
const NAME: &'static str = $name;
fn deps() -> &'static [&'static str] {
$deps
}
fn run(
&self,
input: Self::Input,
_ctx: &PipelineCtx,
) -> Result<Self::Output, PhaseError> {
self.log
.lock()
.expect("log")
.push(format!("{}({input})", $name));
Ok(format!("{}({input})", $name))
}
}
};
}
#[test]
fn ctx_insert_get_remove_round_trip() {
let mut ctx = PipelineCtx::new();
assert!(ctx.is_empty());
ctx.insert("a", 42i32);
assert!(ctx.contains("a"));
assert_eq!(ctx.len(), 1);
assert_eq!(ctx.get::<i32>("a"), Some(&42));
let removed = ctx.remove::<i32>("a");
assert_eq!(removed, Some(42));
assert!(!ctx.contains("a"));
}
#[test]
fn ctx_get_returns_none_for_missing_key() {
let ctx = PipelineCtx::new();
assert_eq!(ctx.get::<i32>("missing"), None);
}
#[test]
fn ctx_get_returns_none_for_type_mismatch() {
let mut ctx = PipelineCtx::new();
ctx.insert("a", 42i32);
assert_eq!(ctx.get::<String>("a"), None);
assert_eq!(ctx.remove::<String>("a"), None);
assert_eq!(ctx.get::<i32>("a"), Some(&42));
}
#[test]
fn ctx_insert_replaces_existing() {
let mut ctx = PipelineCtx::new();
ctx.insert("a", 1i32);
ctx.insert("a", 2i32);
assert_eq!(ctx.get::<i32>("a"), Some(&2));
assert_eq!(ctx.len(), 1);
}
#[test]
fn ctx_debug_shows_keys_not_values() {
let mut ctx = PipelineCtx::new();
ctx.insert("alpha", 1i32);
ctx.insert("beta", String::from("secret"));
let s = format!("{ctx:?}");
assert!(s.contains("alpha"), "got: {s}");
assert!(s.contains("beta"), "got: {s}");
assert!(!s.contains("secret"), "got: {s}");
}
named_phase!(PhaseA, "A", &[]);
#[test]
fn register_single_phase() {
let mut p = Pipeline::new();
assert!(p.is_empty());
p.register(PhaseA::default()).unwrap();
assert!(p.contains("A"));
assert_eq!(p.len(), 1);
}
#[test]
fn register_duplicate_returns_error() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
let err = p.register(PhaseA::default()).unwrap_err();
assert!(matches!(err, PhaseError::DuplicatePhase("A")));
assert_eq!(p.len(), 1);
}
#[test]
fn topo_sort_empty_pipeline() {
let p = Pipeline::new();
let order = p.topo_sort().unwrap();
assert!(order.is_empty());
}
#[test]
fn topo_sort_single_phase() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
let order = p.topo_sort().unwrap();
assert_eq!(order, vec!["A"]);
}
named_phase!(PhaseB, "B", &["A"]);
named_phase!(PhaseC, "C", &["B"]);
#[test]
fn topo_sort_linear_chain() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
p.register(PhaseB::default()).unwrap();
p.register(PhaseC::default()).unwrap();
let order = p.topo_sort().unwrap();
assert_eq!(order, vec!["A", "B", "C"]);
}
named_phase!(PhaseD, "D", &["B", "C"]);
#[test]
fn topo_sort_diamond_dag() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
p.register(PhaseB::default()).unwrap();
p.register(PhaseC::default()).unwrap();
p.register(PhaseD::default()).unwrap();
let order = p.topo_sort().unwrap();
assert_eq!(order.first().copied(), Some("A"));
assert_eq!(order.last().copied(), Some("D"));
let mid = &order[1..3];
assert_eq!(
mid,
&["B", "C"],
"middle phases must be alphabetical: {order:?}"
);
}
named_phase!(PhaseIndependent, "Z", &[]);
#[test]
fn topo_sort_independent_phases_alphabetical() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
p.register(PhaseIndependent::default()).unwrap();
let order = p.topo_sort().unwrap();
assert_eq!(order, vec!["A", "Z"]);
}
named_phase!(CycleB, "CB", &["CA"]);
named_phase!(CycleA, "CA", &["CB"]);
#[test]
fn topo_sort_detects_two_node_cycle() {
let mut p = Pipeline::new();
p.register(CycleA::default()).unwrap();
p.register(CycleB::default()).unwrap();
let err = p.topo_sort().unwrap_err();
match err {
PhaseError::Cycle(msg) => {
assert!(msg.contains("CA"), "got: {msg}");
assert!(msg.contains("CB"), "got: {msg}");
}
other => panic!("expected Cycle, got {other:?}"),
}
}
named_phase!(CycleC, "CC", &["CB"]);
#[test]
fn topo_sort_blocks_downstream_of_cycle() {
let mut p = Pipeline::new();
p.register(CycleA::default()).unwrap(); p.register(CycleB::default()).unwrap(); p.register(CycleC::default()).unwrap(); let err = p.topo_sort().unwrap_err();
match err {
PhaseError::Cycle(msg) => {
assert!(msg.contains("CA"), "got: {msg}");
assert!(msg.contains("CB"), "got: {msg}");
assert!(msg.contains("CC"), "got: {msg}");
}
other => panic!("expected Cycle, got {other:?}"),
}
}
named_phase!(PhaseWithMissingDep, "M", &["nonexistent"]);
#[test]
fn topo_sort_missing_dependency_returns_error() {
let mut p = Pipeline::new();
p.register(PhaseWithMissingDep::default()).unwrap();
let err = p.topo_sort().unwrap_err();
match err {
PhaseError::MissingDependency { phase, dep } => {
assert_eq!(phase, "M");
assert_eq!(dep, "nonexistent");
}
other => panic!("expected MissingDependency, got {other:?}"),
}
}
#[test]
fn run_empty_pipeline_succeeds() {
let p = Pipeline::new();
let mut ctx = PipelineCtx::new();
p.run(&mut ctx).unwrap();
}
#[test]
fn run_single_phase_passes_input_and_stores_output() {
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let phase = PhaseA { log: log.clone() };
let mut p = Pipeline::new();
p.register(phase).unwrap();
let mut ctx = PipelineCtx::new();
ctx.insert("A", String::from("hello"));
p.run(&mut ctx).unwrap();
assert_eq!(ctx.get::<String>("A"), Some(&"A(hello)".to_string()));
assert_eq!(log.lock().unwrap().len(), 1);
}
#[test]
fn run_linear_chain_passes_outputs_in_order() {
struct A;
impl Phase for A {
type Input = ();
type Output = u32;
const NAME: &'static str = "A";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Ok(1)
}
}
struct B;
impl Phase for B {
type Input = ();
type Output = u32;
const NAME: &'static str = "B";
fn deps() -> &'static [&'static str] {
&["A"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let a = ctx.get::<u32>("A").expect("A must run before B");
Ok(a + 1)
}
}
struct C;
impl Phase for C {
type Input = ();
type Output = u32;
const NAME: &'static str = "C";
fn deps() -> &'static [&'static str] {
&["B"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let b = ctx.get::<u32>("B").expect("B must run before C");
Ok(b + 1)
}
}
let mut p = Pipeline::new();
p.register(A).unwrap();
p.register(B).unwrap();
p.register(C).unwrap();
let mut ctx = PipelineCtx::new();
ctx.insert("A", ());
ctx.insert("B", ());
ctx.insert("C", ());
p.run(&mut ctx).unwrap();
assert_eq!(ctx.get::<u32>("A"), Some(&1));
assert_eq!(ctx.get::<u32>("B"), Some(&2));
assert_eq!(ctx.get::<u32>("C"), Some(&3));
}
#[test]
fn run_diamond_dag_reads_shared_dep_output() {
struct A;
impl Phase for A {
type Input = ();
type Output = u32;
const NAME: &'static str = "A";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Ok(10)
}
}
struct B;
impl Phase for B {
type Input = ();
type Output = u32;
const NAME: &'static str = "B";
fn deps() -> &'static [&'static str] {
&["A"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Ok(ctx.get::<u32>("A").copied().unwrap() + 1)
}
}
struct C;
impl Phase for C {
type Input = ();
type Output = u32;
const NAME: &'static str = "C";
fn deps() -> &'static [&'static str] {
&["A"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Ok(ctx.get::<u32>("A").copied().unwrap() + 2)
}
}
struct D;
impl Phase for D {
type Input = ();
type Output = u32;
const NAME: &'static str = "D";
fn deps() -> &'static [&'static str] {
&["B", "C"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let b = ctx.get::<u32>("B").copied().unwrap();
let c = ctx.get::<u32>("C").copied().unwrap();
Ok(b + c)
}
}
let mut p = Pipeline::new();
p.register(A).unwrap();
p.register(B).unwrap();
p.register(C).unwrap();
p.register(D).unwrap();
let mut ctx = PipelineCtx::new();
for n in ["A", "B", "C", "D"] {
ctx.insert(n, ());
}
p.run(&mut ctx).unwrap();
assert_eq!(ctx.get::<u32>("D"), Some(&23));
assert_eq!(ctx.get::<u32>("A"), Some(&10));
}
#[test]
fn run_missing_input_returns_error() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
let mut ctx = PipelineCtx::new();
let err = p.run(&mut ctx).unwrap_err();
assert!(matches!(err, PhaseError::MissingInput("A")));
}
#[test]
fn run_cycle_returns_error_before_execution() {
let mut p = Pipeline::new();
p.register(CycleA::default()).unwrap();
p.register(CycleB::default()).unwrap();
let mut ctx = PipelineCtx::new();
let err = p.run(&mut ctx).unwrap_err();
assert!(matches!(err, PhaseError::Cycle(_)));
assert!(ctx.is_empty());
}
#[test]
fn run_missing_dependency_returns_error_before_execution() {
let mut p = Pipeline::new();
p.register(PhaseWithMissingDep::default()).unwrap();
let mut ctx = PipelineCtx::new();
let err = p.run(&mut ctx).unwrap_err();
assert!(matches!(
err,
PhaseError::MissingDependency {
phase: "M",
dep: "nonexistent"
}
));
}
#[test]
fn run_phase_error_is_wrapped_with_phase_name() {
struct FailPhase;
impl Phase for FailPhase {
type Input = ();
type Output = ();
const NAME: &'static str = "FAIL";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Err(PhaseError::ExecutionFailed {
phase: "FAIL",
inner: Box::new(std::io::Error::other("boom")),
})
}
}
let mut p = Pipeline::new();
p.register(FailPhase).unwrap();
let mut ctx = PipelineCtx::new();
ctx.insert("FAIL", ());
let err = p.run(&mut ctx).unwrap_err();
match err {
PhaseError::ExecutionFailed { phase, inner } => {
assert_eq!(phase, "FAIL");
assert!(inner.to_string().contains("boom"), "got: {inner}");
}
other => panic!("expected ExecutionFailed, got {other:?}"),
}
}
#[test]
fn run_phase_returning_type_mismatch_is_wrapped_as_execution_failed() {
struct OddFailPhase;
impl Phase for OddFailPhase {
type Input = ();
type Output = ();
const NAME: &'static str = "ODD";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Err(PhaseError::TypeMismatch("ODD"))
}
}
let mut p = Pipeline::new();
p.register(OddFailPhase).unwrap();
let mut ctx = PipelineCtx::new();
ctx.insert("ODD", ());
let err = p.run(&mut ctx).unwrap_err();
match err {
PhaseError::ExecutionFailed { phase, inner } => {
assert_eq!(phase, "ODD");
assert!(inner.to_string().contains("ODD"), "got: {inner}");
}
other => panic!("expected ExecutionFailed wrapping TypeMismatch, got {other:?}"),
}
}
#[test]
fn pipeline_ctx_default_is_empty() {
let ctx = PipelineCtx::default();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
}
#[test]
fn pipeline_default_is_empty() {
let p = Pipeline::default();
assert!(p.is_empty());
assert_eq!(p.len(), 0);
}
#[test]
fn pipeline_debug_format_lists_registered_phase_names() {
let mut p = Pipeline::new();
p.register(PhaseA::default()).unwrap();
let s = format!("{p:?}");
assert!(s.contains("Pipeline"), "got: {s}");
assert!(s.contains("A"), "debug output must list phase names: {s}");
}
#[test]
fn pipeline_debug_format_empty_pipeline() {
let p = Pipeline::new();
let s = format!("{p:?}");
assert!(s.contains("Pipeline"), "got: {s}");
}
#[test]
fn run_executes_phases_in_topological_order() {
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
struct A(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl Phase for A {
type Input = ();
type Output = ();
const NAME: &'static str = "A";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
self.0.lock().unwrap().push("A".to_string());
Ok(())
}
}
struct B(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl Phase for B {
type Input = ();
type Output = ();
const NAME: &'static str = "B";
fn deps() -> &'static [&'static str] {
&["A"]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
self.0.lock().unwrap().push("B".to_string());
Ok(())
}
}
struct C(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl Phase for C {
type Input = ();
type Output = ();
const NAME: &'static str = "C";
fn deps() -> &'static [&'static str] {
&["A"]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
self.0.lock().unwrap().push("C".to_string());
Ok(())
}
}
struct D(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl Phase for D {
type Input = ();
type Output = ();
const NAME: &'static str = "D";
fn deps() -> &'static [&'static str] {
&["B", "C"]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
self.0.lock().unwrap().push("D".to_string());
Ok(())
}
}
let mut p = Pipeline::new();
p.register(A(log.clone())).unwrap();
p.register(B(log.clone())).unwrap();
p.register(C(log.clone())).unwrap();
p.register(D(log.clone())).unwrap();
let mut ctx = PipelineCtx::new();
for n in ["A", "B", "C", "D"] {
ctx.insert(n, ());
}
p.run(&mut ctx).unwrap();
let recorded = log.lock().unwrap().clone();
assert_eq!(
recorded,
vec![
"A".to_string(),
"B".to_string(),
"C".to_string(),
"D".to_string()
],
"execution order must match topological order"
);
}
#[test]
fn pipeline_ctx_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PipelineCtx>();
}
#[test]
fn pipeline_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Pipeline>();
}
}