proxima-primitives 0.1.0

The pipe algebra itself: the Pipe trait — an async In -> Result<Out, Err> that becomes a transform, source, sink or observe by choice of types — plus its three tier siblings (SendPipe, UnpinPipe, UnpinSendPipe), Handler, SourcePipe, the payload types Carry, ResponseStream and RequestStream (buffered bodies are plain Bytes), HeaderList, the sans-IO byte-stream traits, runtime-agnostic concurrency primitives, and the Replay/GenericStream transport seam. Everything else in proxima composes what is defined here.
Documentation
#![cfg(feature = "alloc")]

use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;

#[derive(Debug, Clone)]
pub struct PathPattern {
    segments: Vec<Segment>,
    raw: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
    Literal(String),
    Param(String),
    Wildcard(String),
}

impl PathPattern {
    pub fn parse(pattern: &str) -> Self {
        let mut segments = Vec::new();
        for raw in pattern.trim_start_matches('/').split('/') {
            if raw.is_empty() {
                continue;
            }
            if let Some(name) = raw
                .strip_prefix('{')
                .and_then(|inner| inner.strip_suffix('}'))
            {
                if let Some(rest) = name.strip_prefix('*') {
                    segments.push(Segment::Wildcard(rest.to_string()));
                } else {
                    segments.push(Segment::Param(name.to_string()));
                }
                continue;
            }
            segments.push(Segment::Literal(raw.to_string()));
        }
        Self {
            segments,
            raw: pattern.to_string(),
        }
    }

    #[must_use]
    pub fn raw(&self) -> &str {
        &self.raw
    }

    #[must_use]
    pub fn matches(&self, path: &str) -> Option<BTreeMap<String, String>> {
        let mut params = BTreeMap::new();
        let segments_in: Vec<&str> = path
            .trim_start_matches('/')
            .split('/')
            .filter(|segment| !segment.is_empty())
            .collect();
        let mut input_index = 0usize;
        for (pattern_index, segment) in self.segments.iter().enumerate() {
            match segment {
                Segment::Literal(literal) => {
                    if input_index >= segments_in.len() || segments_in[input_index] != literal {
                        return None;
                    }
                    input_index += 1;
                }
                Segment::Param(name) => {
                    if input_index >= segments_in.len() {
                        return None;
                    }
                    params.insert(name.clone(), segments_in[input_index].to_string());
                    input_index += 1;
                }
                Segment::Wildcard(name) => {
                    let remainder = segments_in[input_index..].join("/");
                    let key = if name.is_empty() {
                        "wildcard".to_string()
                    } else {
                        name.clone()
                    };
                    params.insert(key, remainder);
                    input_index = segments_in.len();
                    if pattern_index != self.segments.len() - 1 {
                        return None;
                    }
                }
            }
        }
        if input_index != segments_in.len() {
            return None;
        }
        Some(params)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[proxima::test]
    #[case::root("/", "/", true)]
    #[case::literal_match("/users", "/users", true)]
    #[case::trailing_slash("/users", "/users/", true)]
    #[case::literal_mismatch("/users", "/posts", false)]
    #[case::extra_segment("/users", "/users/42", false)]
    async fn literal_paths(#[case] pattern: &str, #[case] input: &str, #[case] expected: bool) {
        let parsed = PathPattern::parse(pattern);
        assert_eq!(parsed.matches(input).is_some(), expected);
    }

    #[test]
    fn param_segment_captures_value() {
        let pattern = PathPattern::parse("/users/{id}");
        let params = pattern.matches("/users/42").expect("pattern should match");
        assert_eq!(params.get("id"), Some(&"42".into()));
    }

    #[test]
    fn multiple_params_capture_all() {
        let pattern = PathPattern::parse("/users/{user}/posts/{post}");
        let params = pattern
            .matches("/users/alice/posts/12")
            .expect("pattern should match");
        assert_eq!(params.get("user"), Some(&"alice".into()));
        assert_eq!(params.get("post"), Some(&"12".into()));
    }

    #[test]
    fn named_wildcard_captures_remainder() {
        let pattern = PathPattern::parse("/files/{*rest}");
        let params = pattern
            .matches("/files/2026/05/03/notes.md")
            .expect("pattern should match");
        assert_eq!(params.get("rest"), Some(&"2026/05/03/notes.md".into()));
    }

    #[test]
    fn wildcard_only_at_tail() {
        let pattern = PathPattern::parse("/files/{*rest}/extra");
        assert!(pattern.matches("/files/anything/extra").is_none());
    }

    #[test]
    fn param_must_consume_all_input() {
        let pattern = PathPattern::parse("/users/{id}");
        assert!(pattern.matches("/users/42/extra").is_none());
    }
}