a3s-boot 0.1.1

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
use crate::percent::decode_percent_encoded;
use crate::{BootError, Result};
use std::collections::{BTreeMap, BTreeSet};

pub(crate) fn validate_route_path(path: &str) -> Result<()> {
    if !path.starts_with('/') || path.contains(['?', '#']) {
        return Err(BootError::InvalidRoutePath(path.to_string()));
    }

    let mut params = BTreeSet::new();
    let segments = split_path(path);
    for (index, segment) in segments.iter().enumerate() {
        validate_route_segment(path, segment, index == segments.len() - 1, &mut params)?;
    }

    Ok(())
}

pub(super) fn normalize_prefix(prefix: &str) -> Result<String> {
    if prefix.is_empty() || prefix == "/" {
        return Ok(String::new());
    }
    validate_route_path(prefix)?;
    Ok(prefix.trim_end_matches('/').to_string())
}

pub(crate) fn join_paths(prefix: &str, path: &str) -> Result<String> {
    validate_route_path(path)?;
    let prefix = normalize_prefix(prefix)?;
    let path = path.trim_start_matches('/');

    if prefix.is_empty() {
        return Ok(if path.is_empty() {
            "/".to_string()
        } else {
            format!("/{path}")
        });
    }

    let joined = if path.is_empty() {
        prefix
    } else {
        format!("{prefix}/{path}")
    };
    validate_route_path(&joined)?;
    Ok(joined)
}

pub(crate) fn match_path_params(
    pattern: &str,
    path: &str,
) -> Result<Option<BTreeMap<String, String>>> {
    let pattern_segments = split_path(pattern);
    let path_segments = split_path(path);
    let mut params = BTreeMap::new();

    let mut path_index = 0;
    for pattern in &pattern_segments {
        if let Some(name) = route_wildcard_name(pattern) {
            let captured = if path_index >= path_segments.len() {
                String::new()
            } else {
                path_segments[path_index..].join("/")
            };
            params.insert(name.to_string(), decode_path_param(&captured)?);
            return Ok(Some(params));
        }

        let Some(value) = path_segments.get(path_index) else {
            return Ok(None);
        };

        if let Some(name) = route_param_name(pattern) {
            params.insert(name.to_string(), decode_path_param(value)?);
        } else if pattern != value {
            return Ok(None);
        }

        path_index += 1;
    }

    Ok((path_index == path_segments.len()).then_some(params))
}

pub(crate) fn match_path_shape(pattern: &str, path: &str) -> bool {
    let pattern_segments = split_path(pattern);
    let path_segments = split_path(path);
    let mut path_index = 0;

    for pattern in &pattern_segments {
        if route_wildcard_name(pattern).is_some() {
            return true;
        }

        let Some(value) = path_segments.get(path_index) else {
            return false;
        };

        if route_param_name(pattern).is_none() && pattern != value {
            return false;
        }

        path_index += 1;
    }

    path_index == path_segments.len()
}

pub(crate) fn route_shape_key(path: &str) -> String {
    let segments = split_path(path)
        .into_iter()
        .map(|segment| {
            if route_wildcard_name(segment).is_some() {
                "{*}"
            } else if route_param_name(segment).is_some() {
                "{}"
            } else {
                segment
            }
        })
        .collect::<Vec<_>>();

    if segments.is_empty() {
        "/".to_string()
    } else {
        format!("/{}", segments.join("/"))
    }
}

#[cfg(feature = "axum")]
pub(crate) fn has_catch_all(path: &str) -> bool {
    split_path(path)
        .into_iter()
        .any(|segment| route_wildcard_name(segment).is_some())
}

pub(super) fn route_param_names(path: &str) -> Vec<&str> {
    split_path(path)
        .into_iter()
        .filter_map(route_path_param_name)
        .collect()
}

pub(super) fn route_specificity(path: &str) -> Vec<u8> {
    split_path(path)
        .into_iter()
        .map(|segment| {
            if route_wildcard_name(segment).is_some() {
                0
            } else if route_param_name(segment).is_some() {
                1
            } else {
                2
            }
        })
        .collect()
}

fn split_path(path: &str) -> Vec<&str> {
    let path = path.strip_prefix('/').unwrap_or(path);
    if path.is_empty() {
        Vec::new()
    } else {
        path.split('/').collect()
    }
}

fn route_param_name(segment: &str) -> Option<&str> {
    segment
        .strip_prefix('{')
        .and_then(|value| value.strip_suffix('}'))
        .filter(|name| !name.is_empty() && !name.starts_with('*') && !name.contains(['{', '}']))
}

fn route_wildcard_name(segment: &str) -> Option<&str> {
    segment
        .strip_prefix("{*")
        .and_then(|value| value.strip_suffix('}'))
        .filter(|name| !name.is_empty() && !name.contains(['{', '}', '*']))
}

fn route_path_param_name(segment: &str) -> Option<&str> {
    route_param_name(segment).or_else(|| route_wildcard_name(segment))
}

fn validate_route_segment<'a>(
    path: &str,
    segment: &'a str,
    is_last: bool,
    params: &mut BTreeSet<&'a str>,
) -> Result<()> {
    if !segment.contains(['{', '}']) {
        return Ok(());
    }

    let name = if let Some(name) = route_wildcard_name(segment) {
        if !is_last {
            return Err(BootError::InvalidRoutePath(path.to_string()));
        }
        name
    } else {
        route_param_name(segment).ok_or_else(|| BootError::InvalidRoutePath(path.to_string()))?
    };

    if !params.insert(name) {
        return Err(BootError::InvalidRoutePath(path.to_string()));
    }

    Ok(())
}

fn decode_path_param(value: &str) -> Result<String> {
    decode_percent_encoded(value)
}