lance_core/cache/
backend_uri.rs1use std::sync::Arc;
39
40use super::backend::CacheBackend;
41use super::registry::{BackendConfig, build_from_config, normalize_backend_kind};
42use crate::{Error, Result};
43
44pub fn build_from_uri(uri: &str) -> Result<Arc<dyn CacheBackend>> {
52 let config = parse_backend_uri(uri)?;
53 build_from_config(&config)
54}
55
56pub fn parse_backend_uri(uri: &str) -> Result<BackendConfig> {
60 let (scheme, rest) = split_scheme(uri)?;
61 let (path, query) = split_path_query(rest);
62
63 let mut config = BackendConfig::new(&scheme)?;
64
65 let normalized_path = normalize_path(path);
66 if !normalized_path.is_empty() {
67 config.options.insert("path".to_string(), normalized_path);
68 }
69
70 if let Some(query) = query {
71 for raw_pair in query.split('&') {
72 if raw_pair.is_empty() {
73 continue;
74 }
75 let (raw_key, raw_value) = raw_pair.split_once('=').ok_or_else(|| {
76 Error::invalid_input(format!(
77 "cache backend uri {:?}: query pair {:?} is missing '='",
78 uri, raw_pair
79 ))
80 })?;
81 let key = percent_decode(raw_key).map_err(|err| {
82 Error::invalid_input(format!(
83 "cache backend uri {:?}: cannot decode query key {:?}: {}",
84 uri, raw_key, err
85 ))
86 })?;
87 let value = percent_decode(raw_value).map_err(|err| {
88 Error::invalid_input(format!(
89 "cache backend uri {:?}: cannot decode query value {:?}: {}",
90 uri, raw_value, err
91 ))
92 })?;
93 if config.options.contains_key(&key) {
94 return Err(Error::invalid_input(format!(
95 "cache backend uri {:?}: option {:?} is set more than once",
96 uri, key
97 )));
98 }
99 config.options.insert(key, value);
100 }
101 }
102
103 Ok(config)
104}
105
106fn split_scheme(uri: &str) -> Result<(String, &str)> {
107 let colon = uri.find(':').ok_or_else(|| {
108 Error::invalid_input(format!("cache backend uri {:?} is missing ':'", uri))
109 })?;
110 let scheme = &uri[..colon];
111 let scheme = normalize_backend_kind(scheme)
112 .map_err(|err| Error::invalid_input(format!("cache backend uri {:?}: {}", uri, err)))?;
113 Ok((scheme, &uri[colon + 1..]))
114}
115
116fn split_path_query(rest: &str) -> (&str, Option<&str>) {
117 match rest.split_once('?') {
118 Some((path, query)) => (path, Some(query)),
119 None => (rest, None),
120 }
121}
122
123fn normalize_path(raw: &str) -> String {
128 let Some(without_marker) = raw.strip_prefix("//") else {
129 return raw.to_string();
130 };
131 if without_marker.is_empty() {
132 return String::new();
133 }
134 without_marker.to_string()
135}
136
137fn percent_decode(input: &str) -> std::result::Result<String, String> {
138 let bytes = input.as_bytes();
139 let mut out = Vec::with_capacity(bytes.len());
140 let mut i = 0;
141 while i < bytes.len() {
142 match bytes[i] {
143 b'%' => {
144 if i + 2 >= bytes.len() {
145 return Err(format!("truncated percent-escape at offset {}", i));
146 }
147 let hi = decode_hex_digit(bytes[i + 1])?;
148 let lo = decode_hex_digit(bytes[i + 2])?;
149 out.push((hi << 4) | lo);
150 i += 3;
151 }
152 b => {
153 out.push(b);
154 i += 1;
155 }
156 }
157 }
158 String::from_utf8(out).map_err(|err| err.to_string())
159}
160
161fn decode_hex_digit(b: u8) -> std::result::Result<u8, String> {
162 match b {
163 b'0'..=b'9' => Ok(b - b'0'),
164 b'a'..=b'f' => Ok(10 + b - b'a'),
165 b'A'..=b'F' => Ok(10 + b - b'A'),
166 _ => Err(format!(
167 "invalid hex digit {:?} in percent-escape",
168 b as char
169 )),
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_parse_authority_only() {
179 let cfg = parse_backend_uri("moka://?capacity=1073741824").unwrap();
180 assert_eq!(cfg.kind, "moka");
181 assert_eq!(
182 cfg.options.get("capacity").map(String::as_str),
183 Some("1073741824")
184 );
185 assert!(!cfg.options.contains_key("path"));
186 }
187
188 #[test]
189 fn test_parse_path_and_query() {
190 let cfg = parse_backend_uri("example:///var/lance/cache?capacity=10G").unwrap();
191 assert_eq!(cfg.kind, "example");
192 assert_eq!(
193 cfg.options.get("path").map(String::as_str),
194 Some("/var/lance/cache")
195 );
196 assert_eq!(cfg.options.get("capacity").map(String::as_str), Some("10G"));
197 }
198
199 #[test]
200 fn test_parse_host_style() {
201 let cfg = parse_backend_uri("redis://localhost:6379/0?prefix=lance").unwrap();
205 assert_eq!(cfg.kind, "redis");
206 assert_eq!(
207 cfg.options.get("path").map(String::as_str),
208 Some("localhost:6379/0"),
209 );
210 assert_eq!(cfg.options.get("prefix").map(String::as_str), Some("lance"));
211 }
212
213 #[test]
214 fn test_scheme_is_lowercased() {
215 let cfg = parse_backend_uri("MOKA://?capacity=1").unwrap();
219 assert_eq!(cfg.kind, "moka");
220 }
221
222 #[test]
223 fn test_percent_decoding() {
224 let cfg = parse_backend_uri("kv://?prefix=a%2Fb&name=hello%20world&token=a+b%2Bc").unwrap();
225 assert_eq!(cfg.options.get("prefix").map(String::as_str), Some("a/b"));
226 assert_eq!(
227 cfg.options.get("name").map(String::as_str),
228 Some("hello world")
229 );
230 assert_eq!(cfg.options.get("token").map(String::as_str), Some("a+b+c"));
231 }
232
233 #[test]
234 fn test_empty_query_pair_is_skipped() {
235 let cfg = parse_backend_uri("moka://?capacity=1&").unwrap();
237 assert_eq!(cfg.options.len(), 1);
238 }
239
240 #[test]
241 fn test_missing_scheme_errors() {
242 let err = parse_backend_uri("no-scheme-here").unwrap_err();
243 assert!(err.to_string().contains("missing ':'"));
244 }
245
246 #[test]
247 fn test_invalid_scheme_errors() {
248 let err = parse_backend_uri("1moka://").unwrap_err();
251 assert!(err.to_string().contains("must start with an ASCII letter"));
252 }
253
254 #[test]
255 fn test_duplicate_option_errors() {
256 let err = parse_backend_uri("moka://?capacity=1&capacity=2").unwrap_err();
257 assert!(err.to_string().contains("more than once"));
258 }
259
260 #[test]
261 fn test_query_pair_without_equals_errors() {
262 let err = parse_backend_uri("moka://?capacity").unwrap_err();
263 assert!(err.to_string().contains("missing '='"));
264 }
265}