use praxis_core::config::{Condition, FailureMode, ResponseCondition};
use serde::Serialize;
use super::{
branch::{RejoinTarget, ResolvedBranch, ResolvedBranchCondition},
filter::PipelineFilter,
};
use crate::{
FilterPipeline,
any_filter::AnyFilter,
body::{BodyAccess, BodyMode},
filter::HttpFilter,
};
#[derive(Clone, Debug, Serialize)]
pub struct FilterIntrospection {
pub index: usize,
pub filter: String,
pub name: Option<String>,
pub conditions: Vec<Condition>,
pub response_conditions: Vec<ResponseCondition>,
pub failure_mode: FailureMode,
pub phases: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_body: Option<BodyAccessInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_body: Option<BodyAccessInfo>,
pub branches: Vec<BranchIntrospection>,
}
#[derive(Clone, Debug, Serialize)]
pub struct BodyAccessInfo {
pub access: &'static str,
pub mode: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<usize>,
}
#[derive(Clone, Debug, Serialize)]
pub struct BranchIntrospection {
pub name: String,
pub condition: Option<BranchConditionInfo>,
pub filters: Vec<FilterIntrospection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_iterations: Option<u32>,
pub rejoin: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct BranchConditionInfo {
pub filter: String,
pub key: String,
pub result: String,
}
#[expect(
clippy::multiple_inherent_impl,
reason = "pipeline concerns are split across modules"
)]
impl FilterPipeline {
pub fn introspection(&self) -> Vec<FilterIntrospection> {
snapshot_filters(&self.filters)
}
}
fn snapshot_filters(filters: &[PipelineFilter]) -> Vec<FilterIntrospection> {
filters
.iter()
.enumerate()
.map(|(index, pf)| snapshot_filter(index, pf, filters))
.collect()
}
fn snapshot_filter(index: usize, pf: &PipelineFilter, siblings: &[PipelineFilter]) -> FilterIntrospection {
let (phases, request_body, response_body) = phase_and_body_info(pf);
FilterIntrospection {
index,
filter: pf.filter.name().to_owned(),
name: pf.name.as_ref().map(ToString::to_string),
conditions: pf.conditions.clone(),
response_conditions: pf.response_conditions.clone(),
failure_mode: pf.failure_mode,
phases,
request_body,
response_body,
branches: pf
.branches
.iter()
.map(|branch| snapshot_branch(branch, siblings))
.collect(),
}
}
fn phase_and_body_info(pf: &PipelineFilter) -> (Vec<&'static str>, Option<BodyAccessInfo>, Option<BodyAccessInfo>) {
match &pf.filter {
AnyFilter::Http(f) => {
let req_access = HttpFilter::request_body_access(f.as_ref());
let resp_access = HttpFilter::response_body_access(f.as_ref());
let req_mode = HttpFilter::request_body_mode(f.as_ref());
let resp_mode = HttpFilter::response_body_mode(f.as_ref());
(
http_phases(req_access, resp_access, &pf.response_conditions),
Some(body_info(req_access, req_mode)),
Some(body_info(resp_access, resp_mode)),
)
},
AnyFilter::Tcp(_) => (vec!["connect", "disconnect"], None, None),
}
}
fn snapshot_branch(branch: &ResolvedBranch, parent_siblings: &[PipelineFilter]) -> BranchIntrospection {
BranchIntrospection {
name: branch.name.to_string(),
condition: branch.condition.as_ref().map(branch_condition_info),
filters: snapshot_filters(&branch.filters),
max_iterations: branch.max_iterations,
rejoin: rejoin_label(&branch.rejoin, parent_siblings),
}
}
fn branch_condition_info(cond: &ResolvedBranchCondition) -> BranchConditionInfo {
BranchConditionInfo {
filter: cond.filter_name.to_string(),
key: cond.key.to_string(),
result: cond.value.to_string(),
}
}
fn http_phases(
request_body: BodyAccess,
response_body: BodyAccess,
response_conditions: &[ResponseCondition],
) -> Vec<&'static str> {
let mut phases = vec!["request"];
if request_body != BodyAccess::None {
phases.push("request_body");
}
if !response_conditions.is_empty() || response_body != BodyAccess::None {
phases.push("response");
}
if response_body != BodyAccess::None {
phases.push("response_body");
}
phases
}
fn body_info(access: BodyAccess, mode: BodyMode) -> BodyAccessInfo {
let (mode_name, max_bytes) = match mode {
BodyMode::Stream => ("stream", None),
BodyMode::StreamBuffer { max_bytes } => ("stream_buffer", max_bytes),
BodyMode::SizeLimit { max_bytes } => ("size_limit", Some(max_bytes)),
};
BodyAccessInfo {
access: match access {
BodyAccess::None => "none",
BodyAccess::ReadOnly => "read_only",
BodyAccess::ReadWrite => "read_write",
},
mode: mode_name,
max_bytes,
}
}
fn rejoin_label(rejoin: &RejoinTarget, siblings: &[PipelineFilter]) -> String {
match rejoin {
RejoinTarget::Next => "next".to_owned(),
RejoinTarget::Terminal => "terminal".to_owned(),
RejoinTarget::SkipTo(idx) => named_or_index(*idx, siblings),
RejoinTarget::ReEnter(idx) => format!("re-enter:{}", named_or_index(*idx, siblings)),
}
}
fn named_or_index(idx: usize, siblings: &[PipelineFilter]) -> String {
siblings
.get(idx)
.and_then(|pf| pf.name.as_ref())
.map_or_else(|| format!("index:{idx}"), ToString::to_string)
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "tests")]
mod tests {
use super::*;
use crate::FilterRegistry;
#[test]
fn empty_pipeline_introspection() {
let registry = FilterRegistry::with_builtins();
let pipeline = FilterPipeline::build(&mut [], ®istry).expect("empty pipeline builds");
let snap = pipeline.introspection();
assert!(snap.is_empty(), "empty pipeline should introspect to []");
}
#[test]
fn body_info_maps_access_and_mode() {
let info = body_info(BodyAccess::ReadOnly, BodyMode::StreamBuffer { max_bytes: Some(64) });
assert_eq!(info.access, "read_only", "ReadOnly maps to read_only");
assert_eq!(info.mode, "stream_buffer", "StreamBuffer maps to stream_buffer");
assert_eq!(info.max_bytes, Some(64), "max_bytes should round-trip");
}
#[test]
fn http_phases_include_response_when_conditions_present() {
use praxis_core::config::{ResponseCondition, ResponseConditionMatch};
let conditions = vec![ResponseCondition::When(ResponseConditionMatch {
status: Some(vec![500]),
headers: None,
})];
let phases = http_phases(BodyAccess::None, BodyAccess::None, &conditions);
assert_eq!(
phases,
["request", "response"],
"response conditions should add response phase"
);
}
#[test]
fn http_phases_always_include_request() {
let phases = http_phases(BodyAccess::None, BodyAccess::None, &[]);
assert_eq!(phases, ["request"], "request phase is always present");
}
#[test]
fn rejoin_label_distinguishes_skip_to_and_re_enter() {
assert_eq!(
rejoin_label(&RejoinTarget::SkipTo(2), &[]),
"index:2",
"SkipTo should use index when sibling has no name"
);
assert_eq!(
rejoin_label(&RejoinTarget::ReEnter(2), &[]),
"re-enter:index:2",
"ReEnter should prefix re-enter: so loops are visible"
);
assert_eq!(rejoin_label(&RejoinTarget::Next, &[]), "next", "Next stays next");
assert_eq!(
rejoin_label(&RejoinTarget::Terminal, &[]),
"terminal",
"Terminal stays terminal"
);
}
}