use std::{
collections::{BTreeMap, BTreeSet},
fmt::Write as FmtWrite,
};
use serde::Serialize;
use crate::{
dev::ResourceDef,
guard::{Guard, GuardDetail},
http::Method,
};
#[derive(Clone)]
struct RouteDetail {
methods: Vec<Method>,
guards: Vec<String>,
guard_details: Vec<GuardReport>,
patterns: Vec<String>,
resource_name: Option<String>,
is_resource: bool,
}
#[derive(Clone)]
pub(crate) struct RouteInfo {
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
guard_details: Vec<GuardReport>,
patterns: Vec<String>,
resource_name: Option<String>,
}
impl RouteInfo {
pub(crate) fn new(
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
guard_details: Vec<GuardReport>,
patterns: Vec<String>,
resource_name: Option<String>,
) -> Self {
Self {
full_path,
methods,
guards,
guard_details,
patterns,
resource_name,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GuardReport {
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub details: Vec<GuardDetailReport>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GuardDetailReport {
HttpMethods { methods: Vec<String> },
Headers { headers: Vec<HeaderReport> },
Generic { value: String },
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeaderReport {
pub name: String,
pub value: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExternalResourceReportItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub patterns: Vec<String>,
pub origin_scope: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegistrationKind {
Service,
Route,
}
#[derive(Clone)]
struct Registration {
order: usize,
kind: RegistrationKind,
scope_id: Option<usize>,
parent_scope_id: Option<usize>,
full_path: String,
is_prefix: bool,
methods: Vec<Method>,
guards: Vec<String>,
}
#[derive(Clone)]
struct ShadowingContext {
path: String,
order: usize,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum ResourceType {
App,
Scope,
Resource,
}
fn resource_type_label(kind: ResourceType) -> &'static str {
match kind {
ResourceType::App => "app",
ResourceType::Scope => "scope",
ResourceType::Resource => "resource",
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct IntrospectionNode {
pub kind: ResourceType,
pub pattern: String,
pub full_path: String,
pub methods: Vec<Method>,
pub guards: Vec<String>,
pub guard_details: Vec<GuardReport>,
pub resource_name: Option<String>,
pub patterns: Vec<String>,
pub children: Vec<IntrospectionNode>,
pub potentially_unreachable: bool,
pub reachability_notes: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize)]
pub struct IntrospectionReportItem {
pub full_path: String,
pub methods: Vec<String>,
pub guards: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub guards_detail: Vec<GuardReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<String>,
pub resource_type: String,
pub scope_depth: usize,
#[serde(skip_serializing_if = "is_false")]
pub potentially_unreachable: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub reachability_notes: Vec<String>,
}
impl IntrospectionNode {
pub fn new(kind: ResourceType, pattern: String, full_path: String) -> Self {
IntrospectionNode {
kind,
pattern,
full_path,
methods: Vec::new(),
guards: Vec::new(),
guard_details: Vec::new(),
resource_name: None,
patterns: Vec::new(),
children: Vec::new(),
potentially_unreachable: false,
reachability_notes: Vec::new(),
}
}
}
impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
fn from(node: &IntrospectionNode) -> Self {
fn collect_report_items(
node: &IntrospectionNode,
report_items: &mut Vec<IntrospectionReportItem>,
depth: usize,
) {
let include_node = matches!(node.kind, ResourceType::Resource)
|| !node.methods.is_empty()
|| !node.guards.is_empty()
|| node.potentially_unreachable;
if include_node {
let method_names = node
.methods
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>();
let filtered_guards = filter_guard_names(&node.guards, &node.methods);
report_items.push(IntrospectionReportItem {
full_path: node.full_path.clone(),
methods: method_names,
guards: filtered_guards,
guards_detail: node.guard_details.clone(),
resource_name: node.resource_name.clone(),
patterns: node.patterns.clone(),
resource_type: resource_type_label(node.kind).to_string(),
scope_depth: depth,
potentially_unreachable: node.potentially_unreachable,
reachability_notes: node.reachability_notes.clone(),
});
}
for child in &node.children {
collect_report_items(child, report_items, depth + 1);
}
}
let mut report_items = Vec::new();
collect_report_items(node, &mut report_items, 0);
report_items
}
}
#[derive(Clone, Default)]
pub(crate) struct IntrospectionCollector {
details: BTreeMap<String, RouteDetail>,
registrations: Vec<Registration>,
externals: Vec<ExternalResourceReportItem>,
next_registration_order: usize,
next_scope_id: usize,
}
impl IntrospectionCollector {
pub(crate) fn new() -> Self {
Self {
details: BTreeMap::new(),
registrations: Vec::new(),
externals: Vec::new(),
next_registration_order: 0,
next_scope_id: 0,
}
}
pub(crate) fn next_scope_id(&mut self) -> usize {
let scope_id = self.next_scope_id;
self.next_scope_id += 1;
scope_id
}
pub(crate) fn register_service(
&mut self,
info: RouteInfo,
is_resource: bool,
is_prefix: bool,
scope_id: Option<usize>,
parent_scope_id: Option<usize>,
) {
let full_path = normalize_path(&info.full_path);
self.register_pattern_detail(&full_path, &info, is_resource);
self.registrations.push(Registration {
order: self.next_registration_order,
kind: RegistrationKind::Service,
scope_id,
parent_scope_id,
full_path,
is_prefix,
methods: info.methods,
guards: info.guards,
});
self.next_registration_order += 1;
}
pub(crate) fn register_route(&mut self, info: RouteInfo, scope_id: Option<usize>) {
let full_path = normalize_path(&info.full_path);
self.register_pattern_detail(&full_path, &info, true);
self.registrations.push(Registration {
order: self.next_registration_order,
kind: RegistrationKind::Route,
scope_id,
parent_scope_id: None,
full_path,
is_prefix: false,
methods: info.methods,
guards: info.guards,
});
self.next_registration_order += 1;
}
pub(crate) fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) {
let report = external_report_from_rdef(rdef, origin_scope);
if let Some(name) = report.name.as_deref() {
if let Some(existing) = self
.externals
.iter_mut()
.find(|item| item.name.as_deref() == Some(name))
{
*existing = report;
return;
}
}
if !self.externals.contains(&report) {
self.externals.push(report);
}
}
fn register_pattern_detail(&mut self, full_path: &str, info: &RouteInfo, is_resource: bool) {
let full_path = normalize_path(full_path);
self.details
.entry(full_path)
.and_modify(|d| {
update_unique(&mut d.methods, &info.methods);
update_unique(&mut d.guards, &info.guards);
merge_guard_reports(&mut d.guard_details, &info.guard_details);
update_unique(&mut d.patterns, &info.patterns);
if d.resource_name.is_none() {
d.resource_name = info.resource_name.clone();
}
if !d.is_resource && is_resource {
d.is_resource = true;
}
})
.or_insert(RouteDetail {
methods: info.methods.clone(),
guards: info.guards.clone(),
guard_details: info.guard_details.clone(),
patterns: info.patterns.clone(),
resource_name: info.resource_name.clone(),
is_resource,
});
}
pub(crate) fn finalize(&mut self) -> IntrospectionTree {
let detail_registry = std::mem::take(&mut self.details);
let registrations = std::mem::take(&mut self.registrations);
let externals = std::mem::take(&mut self.externals);
let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into());
for (full_path, _) in detail_registry.iter() {
let parts = split_path_segments(full_path);
let mut current_node = &mut root;
let mut assembled = String::new();
for part in parts.iter() {
assembled.push('/');
assembled.push_str(part);
let child_full_path = assembled.clone();
let existing_child_index = current_node
.children
.iter()
.position(|n| n.pattern == *part);
let child_index = if let Some(idx) = existing_child_index {
idx
} else {
let kind = if detail_registry
.get(&child_full_path)
.is_some_and(|d| d.is_resource)
{
ResourceType::Resource
} else {
ResourceType::Scope
};
let new_node = IntrospectionNode::new(kind, part.to_string(), child_full_path);
current_node.children.push(new_node);
current_node.children.len() - 1
};
current_node = &mut current_node.children[child_index];
if let Some(detail) = detail_registry.get(¤t_node.full_path) {
update_unique(&mut current_node.methods, &detail.methods);
update_unique(&mut current_node.guards, &detail.guards);
merge_guard_reports(&mut current_node.guard_details, &detail.guard_details);
update_unique(&mut current_node.patterns, &detail.patterns);
if current_node.resource_name.is_none() {
current_node.resource_name = detail.resource_name.clone();
}
}
}
}
let reachability = analyze_reachability(®istrations);
apply_reachability(&mut root, &reachability);
IntrospectionTree { root, externals }
}
}
#[non_exhaustive]
#[derive(Clone)]
pub struct IntrospectionTree {
pub root: IntrospectionNode,
pub externals: Vec<ExternalResourceReportItem>,
}
impl IntrospectionTree {
pub fn report_as_text(&self) -> String {
warn_release_mode_once();
let report_items: Vec<IntrospectionReportItem> = (&self.root).into();
let mut buf = String::new();
for item in report_items {
let full_path = sanitize_text(&item.full_path);
let methods = item
.methods
.iter()
.map(|method| sanitize_text(method))
.collect::<Vec<_>>();
let guards = item
.guards
.iter()
.map(|guard| sanitize_text(guard))
.collect::<Vec<_>>();
writeln!(
buf,
"{} => Methods: {:?} | Guards: {:?}{}",
full_path,
methods,
guards,
format_reachability(&item)
)
.unwrap();
}
buf
}
pub fn report_as_json(&self) -> String {
warn_release_mode_once();
let report_items: Vec<IntrospectionReportItem> = (&self.root).into();
serde_json::to_string_pretty(&report_items).unwrap()
}
pub fn report_externals_as_json(&self) -> String {
warn_release_mode_once();
serde_json::to_string_pretty(&self.externals).unwrap()
}
}
pub(crate) fn guard_reports_from_iter<'a, I>(guards: I) -> Vec<GuardReport>
where
I: IntoIterator<Item = &'a Box<dyn Guard>>,
{
guards
.into_iter()
.map(|guard| {
let mut details = Vec::new();
if let Some(guard_details) = guard.details() {
for detail in guard_details {
merge_guard_detail_reports(&mut details, detail.into());
}
}
GuardReport {
name: guard.name(),
details,
}
})
.collect()
}
impl From<GuardDetail> for GuardDetailReport {
fn from(detail: GuardDetail) -> Self {
match detail {
GuardDetail::HttpMethods(methods) => GuardDetailReport::HttpMethods { methods },
GuardDetail::Headers(headers) => GuardDetailReport::Headers {
headers: headers
.into_iter()
.map(|(name, value)| HeaderReport { name, value })
.collect(),
},
GuardDetail::Generic(value) => GuardDetailReport::Generic { value },
}
}
}
pub(crate) fn external_report_from_rdef(
rdef: &ResourceDef,
origin_scope: &str,
) -> ExternalResourceReportItem {
ExternalResourceReportItem {
name: rdef.name().map(|name| name.to_string()),
patterns: rdef
.pattern_iter()
.map(|pattern| pattern.to_string())
.collect(),
origin_scope: normalize_path(origin_scope),
}
}
pub(crate) fn expand_patterns(prefix: &str, rdef: &ResourceDef) -> Vec<String> {
let mut full_paths = Vec::new();
if prefix.is_empty() {
for pat in rdef.pattern_iter() {
full_paths.push(normalize_path(pat));
}
return full_paths;
}
let joined = ResourceDef::root_prefix(prefix).join(rdef);
for pat in joined.pattern_iter() {
full_paths.push(normalize_path(pat));
}
full_paths
}
fn analyze_reachability(registrations: &[Registration]) -> BTreeMap<String, Vec<String>> {
let shadowed_scopes = shadowed_scope_context(registrations);
let shadowed_routes = shadowed_route_context(registrations);
let mut notes_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for reg in registrations {
let mut notes = Vec::new();
if let Some(scope_id) = reg.scope_id {
if let Some(context) = shadowed_scopes.get(&scope_id) {
notes.push("shadowed_by_scope".to_string());
notes.push(format!("shadowed_by_path:{}", context.path));
notes.push(format!("shadowed_by_order:{}", context.order));
}
}
if reg.kind == RegistrationKind::Route {
if let Some(context) = shadowed_routes.get(&(reg.scope_id, reg.full_path.clone())) {
notes.push("shadowed_by_route".to_string());
notes.push(format!("shadowed_by_path:{}", context.path));
notes.push(format!("shadowed_by_order:{}", context.order));
}
if has_conflicting_methods(®.methods, ®.guards) {
notes.push("conflicting_method_guards".to_string());
}
}
if !notes.is_empty() {
let entry = notes_by_path.entry(reg.full_path.clone()).or_default();
for note in notes {
entry.insert(note);
}
}
}
notes_by_path
.into_iter()
.map(|(path, notes)| (path, notes.into_iter().collect()))
.collect()
}
fn shadowed_scope_context(registrations: &[Registration]) -> BTreeMap<usize, ShadowingContext> {
let mut groups: BTreeMap<(Option<usize>, String), Vec<&Registration>> = BTreeMap::new();
for reg in registrations {
if reg.kind != RegistrationKind::Service || !reg.is_prefix {
continue;
}
if reg.scope_id.is_none() {
continue;
}
groups
.entry((reg.parent_scope_id, reg.full_path.clone()))
.or_default()
.push(reg);
}
let mut shadowed = BTreeMap::new();
for regs in groups.values_mut() {
regs.sort_by_key(|reg| reg.order);
let mut shadowing_reg = None;
for reg in regs.iter() {
if matches_all(®.methods, ®.guards) {
shadowing_reg = Some(*reg);
break;
}
}
if let Some(shadowing) = shadowing_reg {
for reg in regs.iter() {
if reg.order > shadowing.order {
let scope_id = reg.scope_id.expect("scope_id must exist");
shadowed.insert(
scope_id,
ShadowingContext {
path: shadowing.full_path.clone(),
order: shadowing.order,
},
);
}
}
}
}
shadowed
}
fn shadowed_route_context(
registrations: &[Registration],
) -> BTreeMap<(Option<usize>, String), ShadowingContext> {
let mut groups: BTreeMap<(Option<usize>, String), Vec<&Registration>> = BTreeMap::new();
for reg in registrations {
if reg.kind != RegistrationKind::Route {
continue;
}
groups
.entry((reg.scope_id, reg.full_path.clone()))
.or_default()
.push(reg);
}
let mut shadowed = BTreeMap::new();
for (key, regs) in groups {
let mut regs = regs;
regs.sort_by_key(|reg| reg.order);
for idx in 1..regs.len() {
let current = regs[idx];
let current_methods = method_set(¤t.methods);
if !guards_only_methods(¤t.guards, ¤t.methods) {
continue;
}
let mut shadowing_reg = None;
for earlier in ®s[..idx] {
if !guards_only_methods(&earlier.guards, &earlier.methods) {
continue;
}
if earlier.methods.is_empty() {
shadowing_reg = Some(*earlier);
break;
}
let earlier_methods = method_set(&earlier.methods);
if !current_methods.is_empty() && current_methods.is_subset(&earlier_methods) {
shadowing_reg = Some(*earlier);
break;
}
}
if let Some(reg) = shadowing_reg {
shadowed.insert(
key.clone(),
ShadowingContext {
path: reg.full_path.clone(),
order: reg.order,
},
);
break;
}
}
}
shadowed
}
fn apply_reachability(root: &mut IntrospectionNode, notes: &BTreeMap<String, Vec<String>>) {
fn apply(node: &mut IntrospectionNode, notes: &BTreeMap<String, Vec<String>>) {
if let Some(node_notes) = notes.get(&node.full_path) {
node.potentially_unreachable = true;
node.reachability_notes = node_notes.clone();
}
for child in &mut node.children {
apply(child, notes);
}
}
apply(root, notes);
}
fn normalize_path(path: &str) -> String {
if path.is_empty() {
return "/".to_string();
}
if path.starts_with('/') {
path.to_string()
} else {
let mut buf = String::with_capacity(path.len() + 1);
buf.push('/');
buf.push_str(path);
buf
}
}
fn split_path_segments(path: &str) -> Vec<&str> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if trimmed.is_empty() {
return vec![""];
}
trimmed.split('/').collect()
}
fn matches_all(methods: &[Method], guards: &[String]) -> bool {
methods.is_empty() && filter_guard_names(guards, methods).is_empty()
}
fn guards_only_methods(guards: &[String], methods: &[Method]) -> bool {
filter_guard_names(guards, methods).is_empty()
}
fn has_conflicting_methods(methods: &[Method], guards: &[String]) -> bool {
if method_set(methods).len() <= 1 {
return false;
}
fn split_top_level_args(mut args: &str) -> Vec<&str> {
args = args.trim();
if args.is_empty() {
return Vec::new();
}
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (idx, ch) in args.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
parts.push(args[start..idx].trim());
start = idx + 1;
}
_ => {}
}
}
parts.push(args[start..].trim());
parts.into_iter().filter(|s| !s.is_empty()).collect()
}
fn parse_method(name: &str) -> Option<BTreeSet<String>> {
name.trim().parse::<Method>().ok().map(|method| {
let mut set = BTreeSet::new();
set.insert(method.to_string());
set
})
}
fn union_methods(
left: Option<BTreeSet<String>>,
right: Option<BTreeSet<String>>,
) -> Option<BTreeSet<String>> {
match (left, right) {
(None, _) | (_, None) => None,
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
}
}
fn intersect_methods(
left: Option<BTreeSet<String>>,
right: Option<BTreeSet<String>>,
) -> Option<BTreeSet<String>> {
match (left, right) {
(None, x) | (x, None) => x,
(Some(a), Some(b)) => Some(a.intersection(&b).cloned().collect()),
}
}
fn guard_possible_methods(name: &str) -> Option<BTreeSet<String>> {
let name = name.trim();
if name.is_empty() {
return None;
}
if let Some(set) = parse_method(name) {
return Some(set);
}
if let Some(inner) = name
.strip_prefix("AnyGuard(")
.and_then(|s| s.strip_suffix(')'))
{
let mut acc = Some(BTreeSet::new());
for arg in split_top_level_args(inner) {
acc = union_methods(acc, guard_possible_methods(arg));
if acc.is_none() {
break;
}
}
return acc;
}
if let Some(inner) = name
.strip_prefix("AllGuard(")
.and_then(|s| s.strip_suffix(')'))
{
let mut acc = None;
for arg in split_top_level_args(inner) {
acc = intersect_methods(acc, guard_possible_methods(arg));
if matches!(acc, Some(ref set) if set.is_empty()) {
break;
}
}
return acc;
}
None
}
let mut possible = None;
for guard in guards {
possible = intersect_methods(possible, guard_possible_methods(guard));
if matches!(possible, Some(ref set) if set.is_empty()) {
return true;
}
}
false
}
fn method_set(methods: &[Method]) -> BTreeSet<String> {
methods.iter().map(|m| m.to_string()).collect()
}
fn filter_guard_names(guards: &[String], methods: &[Method]) -> Vec<String> {
let method_names = method_set(methods);
guards
.iter()
.filter(|guard| !method_names.iter().any(|method| method == *guard))
.cloned()
.collect()
}
fn merge_guard_reports(existing: &mut Vec<GuardReport>, incoming: &[GuardReport]) {
for report in incoming {
if let Some(existing_report) = existing.iter_mut().find(|r| r.name == report.name) {
for detail in &report.details {
merge_guard_detail_reports(&mut existing_report.details, detail.clone());
}
} else {
existing.push(report.clone());
}
}
}
fn merge_guard_detail_reports(existing: &mut Vec<GuardDetailReport>, incoming: GuardDetailReport) {
match incoming {
GuardDetailReport::HttpMethods { methods } => {
if let Some(existing_methods) = existing.iter_mut().find_map(|detail| {
if let GuardDetailReport::HttpMethods { methods } = detail {
Some(methods)
} else {
None
}
}) {
update_unique(existing_methods, &methods);
} else {
existing.push(GuardDetailReport::HttpMethods { methods });
}
}
GuardDetailReport::Headers { headers } => {
if let Some(existing_headers) = existing.iter_mut().find_map(|detail| {
if let GuardDetailReport::Headers { headers } = detail {
Some(headers)
} else {
None
}
}) {
update_unique(existing_headers, &headers);
} else {
existing.push(GuardDetailReport::Headers { headers });
}
}
GuardDetailReport::Generic { value } => {
let detail = GuardDetailReport::Generic { value };
if !existing.contains(&detail) {
existing.push(detail);
}
}
}
}
fn update_unique<T: Clone + PartialEq>(existing: &mut Vec<T>, new_items: &[T]) {
for item in new_items {
if !existing.contains(item) {
existing.push(item.clone());
}
}
}
fn is_false(value: &bool) -> bool {
!*value
}
fn format_reachability(item: &IntrospectionReportItem) -> String {
if !item.potentially_unreachable {
return String::new();
}
if item.reachability_notes.is_empty() {
" | PotentiallyUnreachable".to_string()
} else {
let notes = item
.reachability_notes
.iter()
.map(|note| sanitize_text(note))
.collect::<Vec<_>>();
format!(" | PotentiallyUnreachable | Notes: {:?}", notes)
}
}
fn sanitize_text(value: &str) -> String {
let mut buf = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_control() {
let code = ch as u32;
if code <= 0xFF {
write!(buf, "\\x{:02x}", code).unwrap();
} else {
write!(buf, "\\u{{{:x}}}", code).unwrap();
}
} else {
buf.push(ch);
}
}
buf
}
fn warn_release_mode_once() {
#[cfg(not(debug_assertions))]
{
use std::sync::Once;
static WARN_ONCE: Once = Once::new();
WARN_ONCE.call_once(|| {
log::warn!(
"experimental-introspection is intended for local/non-production use; \
avoid exposing introspection endpoints in production"
);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn route_info(
full_path: &str,
methods: Vec<Method>,
guards: Vec<String>,
guard_details: Vec<GuardReport>,
patterns: Vec<String>,
resource_name: Option<String>,
) -> RouteInfo {
RouteInfo::new(
full_path.to_string(),
methods,
guards,
guard_details,
patterns,
resource_name,
)
}
#[test]
fn report_includes_resources_without_methods() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/no-guards",
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/no-guards")
.expect("missing resource without guards");
assert!(item.methods.is_empty());
assert!(item.guards.is_empty());
assert_eq!(item.resource_type, "resource");
assert!(!item.potentially_unreachable);
assert!(item.reachability_notes.is_empty());
}
#[test]
fn report_includes_guard_details_and_metadata() {
let mut collector = IntrospectionCollector::new();
let guard_details = vec![GuardReport {
name: "Header(accept, text/plain)".to_string(),
details: vec![GuardDetailReport::Headers {
headers: vec![HeaderReport {
name: "accept".to_string(),
value: "text/plain".to_string(),
}],
}],
}];
let info = route_info(
"/meta",
vec![Method::GET],
vec!["Header(accept, text/plain)".to_string()],
guard_details,
vec!["/meta".to_string()],
Some("meta-resource".to_string()),
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/meta")
.expect("missing metadata route");
assert_eq!(item.resource_name.as_deref(), Some("meta-resource"));
assert!(item.patterns.contains(&"/meta".to_string()));
assert_eq!(item.resource_type, "resource");
assert_eq!(item.scope_depth, 1);
assert_eq!(item.guards_detail.len(), 1);
}
#[test]
fn expand_patterns_handles_scope_paths() {
let empty = ResourceDef::new("");
let slash = ResourceDef::new("/");
assert_eq!(expand_patterns("/app", &empty), vec!["/app"]);
assert_eq!(expand_patterns("/app", &slash), vec!["/app/"]);
assert_eq!(expand_patterns("/app/", &empty), vec!["/app/"]);
assert_eq!(expand_patterns("/app/", &slash), vec!["/app//"]);
}
#[test]
fn expand_patterns_handles_multi_patterns() {
let rdef = ResourceDef::new(["/a", "/b"]);
assert_eq!(expand_patterns("/api", &rdef), vec!["/api/a", "/api/b"]);
}
#[test]
fn conflicting_method_guards_mark_unreachable() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/all-guard",
vec![Method::GET, Method::POST],
vec!["AllGuard(GET, POST)".to_string()],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/all-guard")
.expect("missing route");
assert!(item.potentially_unreachable);
assert!(item
.reachability_notes
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn allguard_anyguard_does_not_mark_conflict_when_methods_are_feasible() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/feasible",
vec![Method::GET, Method::POST],
vec![
"AllGuard(AnyGuard(GET, POST), Header(x, y))".to_string(),
"Header(x, y)".to_string(),
],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/feasible")
.expect("missing route");
assert!(!item.potentially_unreachable);
assert!(!item
.reachability_notes
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn allguard_anyguard_marks_conflict_when_methods_are_impossible() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/impossible",
vec![Method::GET, Method::POST],
vec!["AllGuard(GET, AnyGuard(POST))".to_string()],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/impossible")
.expect("missing route");
assert!(item.potentially_unreachable);
assert!(item
.reachability_notes
.contains(&"conflicting_method_guards".to_string()));
}
#[test]
fn shadowed_scopes_mark_routes() {
let mut collector = IntrospectionCollector::new();
let scope_a = collector.next_scope_id();
let info = route_info(
"/extra",
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
);
collector.register_service(info, true, true, Some(scope_a), None);
let info = route_info(
"/extra/ping",
vec![Method::GET],
Vec::new(),
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, Some(scope_a));
let scope_b = collector.next_scope_id();
let info = route_info(
"/extra",
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
);
collector.register_service(info, true, true, Some(scope_b), None);
let info = route_info(
"/extra/ping",
vec![Method::POST],
Vec::new(),
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, Some(scope_b));
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/extra/ping")
.expect("missing route");
assert!(item.potentially_unreachable);
assert!(item
.reachability_notes
.contains(&"shadowed_by_scope".to_string()));
assert!(item
.reachability_notes
.contains(&"shadowed_by_path:/extra".to_string()));
assert!(item
.reachability_notes
.contains(&"shadowed_by_order:0".to_string()));
}
#[test]
fn shadowed_routes_include_context() {
let mut collector = IntrospectionCollector::new();
let info = route_info(
"/shadow",
vec![Method::GET],
vec!["GET".to_string()],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let info = route_info(
"/shadow",
vec![Method::GET],
vec!["GET".to_string()],
Vec::new(),
Vec::new(),
None,
);
collector.register_route(info, None);
let tree = collector.finalize();
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
let item = items
.iter()
.find(|item| item.full_path == "/shadow")
.expect("missing route");
assert!(item.potentially_unreachable);
assert!(item
.reachability_notes
.contains(&"shadowed_by_route".to_string()));
assert!(item
.reachability_notes
.contains(&"shadowed_by_path:/shadow".to_string()));
assert!(item
.reachability_notes
.contains(&"shadowed_by_order:0".to_string()));
}
}