use std::collections::HashMap;
use std::sync::Mutex;
use crate::events::EventEmitter;
#[derive(Debug, Clone, PartialEq)]
pub struct RouteMatch {
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct RouteState {
pub current_path: String,
pub params: HashMap<String, String>,
pub query: HashMap<String, String>,
pub previous_path: Option<String>,
}
pub struct HypenRouter {
inner: Mutex<RouterInner>,
events: EventEmitter,
}
struct RouterInner {
current_path: String,
params: HashMap<String, String>,
query: HashMap<String, String>,
previous_path: Option<String>,
history: Vec<String>,
}
impl HypenRouter {
pub fn new() -> Self {
Self {
inner: Mutex::new(RouterInner {
current_path: "/".to_string(),
params: HashMap::new(),
query: HashMap::new(),
previous_path: None,
history: vec!["/".to_string()],
}),
events: EventEmitter::new(),
}
}
pub fn push(&self, path: &str) {
let (clean_path, query) = parse_path_and_query(path);
let mut inner = self.inner.lock().unwrap();
let prev = inner.current_path.clone();
inner.previous_path = Some(prev);
inner.current_path = clean_path.clone();
inner.query = query;
inner.params.clear();
inner.history.push(clean_path);
drop(inner);
self.events.emit(
crate::events::framework::ROUTE_CHANGED,
&serde_json::json!({
"path": path,
}),
);
}
pub fn replace(&self, path: &str) {
let (clean_path, query) = parse_path_and_query(path);
let mut inner = self.inner.lock().unwrap();
inner.current_path = clean_path.clone();
inner.query = query;
inner.params.clear();
if let Some(last) = inner.history.last_mut() {
*last = clean_path;
}
drop(inner);
self.events.emit(
crate::events::framework::ROUTE_CHANGED,
&serde_json::json!({ "path": path }),
);
}
pub fn back(&self) {
let prev = {
let mut inner = self.inner.lock().unwrap();
if inner.history.len() < 2 {
return;
}
inner.history.pop();
let prev = inner.history.last().cloned().unwrap();
inner.previous_path = Some(inner.current_path.clone());
inner.current_path = prev.clone();
inner.query.clear();
inner.params.clear();
prev
};
self.events.emit(
crate::events::framework::ROUTE_CHANGED,
&serde_json::json!({ "path": prev }),
);
}
pub fn off(&self, id: crate::events::SubscriptionId) {
self.events.off(id);
}
pub fn state(&self) -> RouteState {
let inner = self.inner.lock().unwrap();
RouteState {
current_path: inner.current_path.clone(),
params: inner.params.clone(),
query: inner.query.clone(),
previous_path: inner.previous_path.clone(),
}
}
pub fn current_path(&self) -> String {
self.inner.lock().unwrap().current_path.clone()
}
pub fn query(&self) -> HashMap<String, String> {
self.inner.lock().unwrap().query.clone()
}
pub fn match_path(&self, pattern: &str, path: &str) -> Option<RouteMatch> {
let (clean_path, query) = parse_path_and_query(path);
hypen_engine::match_path(pattern, &clean_path).map(|m| RouteMatch {
params: m.params.into_iter().collect(),
query,
path: clean_path,
})
}
pub fn is_active(&self, pattern: &str) -> bool {
let inner = self.inner.lock().unwrap();
hypen_engine::match_path(pattern, &inner.current_path).is_some()
}
pub fn on_navigate<F>(&self, handler: F) -> crate::events::SubscriptionId
where
F: Fn(&serde_json::Value) + Send + Sync + 'static,
{
self.events
.on(crate::events::framework::ROUTE_CHANGED, handler)
}
pub fn build_url(path: &str, query: &HashMap<String, String>) -> String {
let sorted: std::collections::BTreeMap<String, String> = query
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
hypen_engine::build_url(path, &sorted)
}
}
impl Default for HypenRouter {
fn default() -> Self {
Self::new()
}
}
fn parse_path_and_query(full_path: &str) -> (String, HashMap<String, String>) {
let (path, btree) = hypen_engine::parse_query(full_path);
(path, btree.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push_and_state() {
let router = HypenRouter::new();
router.push("/users/42?tab=profile");
let state = router.state();
assert_eq!(state.current_path, "/users/42");
assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
assert_eq!(state.previous_path, Some("/".to_string()));
}
#[test]
fn test_replace() {
let router = HypenRouter::new();
router.push("/page1");
router.replace("/page2");
let state = router.state();
assert_eq!(state.current_path, "/page2");
assert_eq!(state.previous_path, Some("/".to_string()));
}
#[test]
fn test_match_exact() {
let router = HypenRouter::new();
let m = router.match_path("/dashboard", "/dashboard");
assert!(m.is_some());
assert!(m.unwrap().params.is_empty());
}
#[test]
fn test_match_params() {
let router = HypenRouter::new();
let m = router
.match_path("/users/:id/posts/:postId", "/users/42/posts/99")
.unwrap();
assert_eq!(m.params["id"], "42");
assert_eq!(m.params["postId"], "99");
}
#[test]
fn test_match_wildcard() {
let router = HypenRouter::new();
assert!(router.match_path("/api/*", "/api/users/list").is_some());
assert!(router.match_path("/api/*", "/api").is_some());
assert!(router.match_path("/api/*", "/other").is_none());
}
#[test]
fn test_no_match() {
let router = HypenRouter::new();
assert!(router.match_path("/users/:id", "/posts/42").is_none());
assert!(router.match_path("/users/:id", "/users/42/extra").is_none());
}
#[test]
fn test_is_active() {
let router = HypenRouter::new();
router.push("/users/42");
assert!(router.is_active("/users/:id"));
assert!(!router.is_active("/posts/:id"));
}
#[test]
fn test_query_parsing() {
let (path, query) = parse_path_and_query("/search?q=hello&page=2");
assert_eq!(path, "/search");
assert_eq!(query["q"], "hello");
assert_eq!(query["page"], "2");
}
#[test]
fn test_build_url() {
let mut query = HashMap::new();
query.insert("tab".to_string(), "profile".to_string());
let url = HypenRouter::build_url("/users/42", &query);
assert_eq!(url, "/users/42?tab=profile");
}
#[test]
fn test_build_url_no_query() {
let url = HypenRouter::build_url("/home", &HashMap::new());
assert_eq!(url, "/home");
}
#[test]
fn test_build_url_encodes_special_chars() {
let mut query = HashMap::new();
query.insert("msg".to_string(), "hello world".to_string());
query.insert("a&b".to_string(), "1=2".to_string());
let url = HypenRouter::build_url("/search", &query);
assert!(url.contains("msg=hello%20world"));
assert!(url.contains("a%26b=1%3D2"));
assert!(url.starts_with("/search?"));
}
#[test]
fn test_parse_decodes_encoded_query() {
let (path, query) = parse_path_and_query("/search?msg=hello%20world&a%26b=1%3D2");
assert_eq!(path, "/search");
assert_eq!(query.get("msg").map(String::as_str), Some("hello world"));
assert_eq!(query.get("a&b").map(String::as_str), Some("1=2"));
}
#[test]
fn test_plus_decodes_to_space() {
let (path, query) = parse_path_and_query("/search?q=hello+world");
assert_eq!(path, "/search");
assert_eq!(query.get("q").map(String::as_str), Some("hello world"));
}
#[test]
fn test_on_navigate() {
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
let router = HypenRouter::new();
let count = Arc::new(AtomicI32::new(0));
let count_clone = count.clone();
router.on_navigate(move |_| {
count_clone.fetch_add(1, Ordering::SeqCst);
});
router.push("/a");
router.push("/b");
assert_eq!(count.load(Ordering::SeqCst), 2);
}
}