1use jsdet_core::observation::Value;
8
9#[derive(Debug, Clone, Default, serde::Serialize)]
11pub struct Location {
12 pub href: String,
13 pub protocol: String,
14 pub hostname: String,
15 pub port: String,
16 pub pathname: String,
17 pub search: String,
18 pub hash: String,
19 pub origin: String,
20}
21
22impl Location {
23 pub fn from_url(url: &str) -> Self {
24 let (protocol, rest) = url.split_once("://").unwrap_or(("https", url));
25 let (authority, path_and_query) = rest.split_once('/').unwrap_or((rest, ""));
26 let (host, port) = if authority.contains(':') {
27 let (h, p) = authority.rsplit_once(':').unwrap();
28 (h.to_string(), p.to_string())
29 } else {
30 (authority.to_string(), String::new())
31 };
32 let (path_with_query, hash) = path_and_query
33 .split_once('#')
34 .unwrap_or((path_and_query, ""));
35 let (pathname, search) = path_with_query
36 .split_once('?')
37 .unwrap_or((path_with_query, ""));
38
39 let origin = if port.is_empty() {
40 format!("{protocol}://{host}")
41 } else {
42 format!("{protocol}://{host}:{port}")
43 };
44
45 Self {
46 href: url.to_string(),
47 protocol: format!("{protocol}:"),
48 hostname: host,
49 port,
50 pathname: format!("/{pathname}"),
51 search: if search.is_empty() {
52 String::new()
53 } else {
54 format!("?{search}")
55 },
56 hash: if hash.is_empty() {
57 String::new()
58 } else {
59 format!("#{hash}")
60 },
61 origin,
62 }
63 }
64
65 pub fn get_property(&self, prop: &str) -> Value {
66 match prop {
67 "href" => Value::string(self.href.clone()),
68 "protocol" => Value::string(self.protocol.clone()),
69 "hostname" | "host" => Value::string(self.hostname.clone()),
70 "port" => Value::string(self.port.clone()),
71 "pathname" => Value::string(self.pathname.clone()),
72 "search" => Value::string(self.search.clone()),
73 "hash" => Value::string(self.hash.clone()),
74 "origin" => Value::string(self.origin.clone()),
75 _ => Value::Undefined,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct History {
83 entries: Vec<String>,
84 index: usize,
85}
86
87impl History {
88 pub fn new(initial_url: &str) -> Self {
89 Self {
90 entries: vec![initial_url.to_string()],
91 index: 0,
92 }
93 }
94
95 pub fn push_state(&mut self, url: &str) {
96 self.entries.truncate(self.index + 1);
97 self.entries.push(url.to_string());
98 self.index = self.entries.len() - 1;
99 }
100
101 pub fn replace_state(&mut self, url: &str) {
102 if let Some(entry) = self.entries.get_mut(self.index) {
103 *entry = url.to_string();
104 }
105 }
106
107 pub fn back(&mut self) -> Option<&str> {
108 if self.index > 0 {
109 self.index -= 1;
110 self.entries.get(self.index).map(|s| s.as_str())
111 } else {
112 None
113 }
114 }
115
116 pub fn forward(&mut self) -> Option<&str> {
117 if self.index + 1 < self.entries.len() {
118 self.index += 1;
119 self.entries.get(self.index).map(|s| s.as_str())
120 } else {
121 None
122 }
123 }
124
125 pub fn length(&self) -> usize {
126 self.entries.len()
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn location_parses_full_url() {
136 let loc = Location::from_url("https://example.com:8080/path?q=1#frag");
137 assert_eq!(loc.protocol, "https:");
138 assert_eq!(loc.hostname, "example.com");
139 assert_eq!(loc.port, "8080");
140 assert_eq!(loc.pathname, "/path");
141 assert_eq!(loc.search, "?q=1");
142 assert_eq!(loc.hash, "#frag");
143 }
144
145 #[test]
146 fn history_push_and_back() {
147 let mut h = History::new("https://a.com");
148 h.push_state("https://b.com");
149 h.push_state("https://c.com");
150 assert_eq!(h.length(), 3);
151 assert_eq!(h.back(), Some("https://b.com"));
152 assert_eq!(h.back(), Some("https://a.com"));
153 assert_eq!(h.forward(), Some("https://b.com"));
154 }
155}