mcpls_core/bridge/
resources.rs1use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use thiserror::Error;
12use tokio::sync::RwLock;
13use url::Url;
14
15const SCHEME: &str = "lsp-diagnostics";
17
18const PREFIX: &str = "lsp-diagnostics://";
23
24pub const MAX_SUBSCRIPTIONS: usize = 1_000;
28
29#[derive(Debug, Error)]
31pub enum ResourceUriError {
32 #[error("path must be absolute and valid UTF-8: {0}")]
34 InvalidPath(String),
35
36 #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
38 InvalidScheme(String),
39
40 #[error("failed to decode URI to filesystem path: {0}")]
42 DecodeFailed(String),
43}
44
45pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
65 let file_url = Url::from_file_path(path)
66 .map_err(|()| ResourceUriError::InvalidPath(path.display().to_string()))?;
67
68 let uri = format!("{SCHEME}://{}", &file_url[url::Position::BeforeHost..]);
71 Ok(uri)
72}
73
74pub fn parse_uri(uri: &str) -> Result<PathBuf, ResourceUriError> {
93 if !uri.starts_with(PREFIX) {
94 return Err(ResourceUriError::InvalidScheme(uri.to_string()));
95 }
96
97 let after_prefix = &uri[PREFIX.len()..];
100 if !after_prefix.starts_with('/') {
101 return Err(ResourceUriError::InvalidScheme(format!(
102 "non-empty authority in URI: {uri}"
103 )));
104 }
105
106 let file_uri = format!("file://{after_prefix}");
107 let url = Url::parse(&file_uri).map_err(|e| ResourceUriError::DecodeFailed(e.to_string()))?;
108
109 url.to_file_path()
110 .map_err(|()| ResourceUriError::DecodeFailed(file_uri))
111}
112
113#[derive(Debug)]
118pub struct ResourceSubscriptions(RwLock<HashSet<String>>);
119
120impl Default for ResourceSubscriptions {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl ResourceSubscriptions {
127 #[must_use]
129 pub fn new() -> Self {
130 Self(RwLock::new(HashSet::new()))
131 }
132
133 pub async fn subscribe(&self, uri: String) -> Result<bool, String> {
142 let mut set = self.0.write().await;
143 if !set.contains(&uri) && set.len() >= MAX_SUBSCRIPTIONS {
144 return Err(format!("subscription limit of {MAX_SUBSCRIPTIONS} reached"));
145 }
146 Ok(set.insert(uri))
147 }
148
149 pub async fn is_empty(&self) -> bool {
154 self.0.read().await.is_empty()
155 }
156
157 pub async fn unsubscribe(&self, uri: &str) -> bool {
161 self.0.write().await.remove(uri)
162 }
163
164 pub async fn contains(&self, uri: &str) -> bool {
166 self.0.read().await.contains(uri)
167 }
168
169 pub async fn snapshot(&self) -> Vec<String> {
171 self.0.read().await.iter().cloned().collect()
172 }
173}
174
175#[cfg(test)]
176#[allow(clippy::unwrap_used, clippy::expect_used)]
177mod tests {
178 use super::*;
179
180 #[test]
185 fn test_make_uri_rejects_relative_path() {
186 let result = make_uri(Path::new("relative/path.rs"));
187 assert!(result.is_err());
188 }
189
190 #[test]
191 fn test_parse_uri_rejects_wrong_scheme() {
192 let result = parse_uri("file:///home/user/main.rs");
193 assert!(result.is_err());
194 }
195
196 #[test]
197 fn test_parse_uri_rejects_http_scheme() {
198 let result = parse_uri("https://example.com/file.rs");
199 assert!(result.is_err());
200 }
201
202 #[cfg(unix)]
203 #[test]
204 fn test_make_uri_simple_path() {
205 let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
206 assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
207 }
208
209 #[cfg(unix)]
210 #[test]
211 fn test_make_uri_scheme_prefix() {
212 let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
213 assert!(uri.starts_with("lsp-diagnostics:///"));
214 }
215
216 #[cfg(unix)]
217 #[test]
218 fn test_parse_uri_simple() {
219 let path = PathBuf::from("/home/user/main.rs");
220 let uri = make_uri(&path).unwrap();
221 let recovered = parse_uri(&uri).unwrap();
222 assert_eq!(recovered, path);
223 }
224
225 #[cfg(unix)]
227 #[test]
228 fn test_round_trip_special_chars() {
229 let paths = [
230 "/home/user/my file.rs",
231 "/tmp/café/main.rs",
232 "/data/100%/test.rs",
233 "/workspace/query?param/file.rs",
234 "/repo/branch#fragment/src.rs",
235 "/путь/к/файлу.rs",
236 ];
237
238 for raw in &paths {
239 let path = PathBuf::from(raw);
240 let uri = make_uri(&path).expect(raw);
241 assert!(
242 uri.starts_with("lsp-diagnostics:///"),
243 "URI should start with correct scheme: {uri}"
244 );
245 let recovered = parse_uri(&uri).expect(&uri);
246 assert_eq!(recovered, path, "Round-trip failed for: {raw}");
247 }
248 }
249
250 #[cfg(unix)]
252 #[test]
253 fn test_wire_format_percent_encoded() {
254 let path = Path::new("/home/user/my file.rs");
255 let uri = make_uri(path).unwrap();
256 assert!(uri.contains("%20"), "Expected %20 in: {uri}");
258 assert!(uri.starts_with("lsp-diagnostics:///"));
259 }
260
261 #[tokio::test]
266 async fn test_subscribe_and_contains() {
267 let subs = ResourceSubscriptions::new();
268 let uri = "lsp-diagnostics:///home/user/main.rs".to_string();
269
270 assert!(!subs.contains(&uri).await);
271 assert!(subs.subscribe(uri.clone()).await.unwrap());
272 assert!(subs.contains(&uri).await);
273 }
274
275 #[tokio::test]
276 async fn test_subscribe_duplicate_returns_false() {
277 let subs = ResourceSubscriptions::new();
278 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
279 assert!(subs.subscribe(uri.clone()).await.unwrap());
280 assert!(!subs.subscribe(uri).await.unwrap());
281 }
282
283 #[tokio::test]
284 async fn test_unsubscribe() {
285 let subs = ResourceSubscriptions::new();
286 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
287 subs.subscribe(uri.clone()).await.unwrap();
288 assert!(subs.unsubscribe(&uri).await);
289 assert!(!subs.contains(&uri).await);
290 }
291
292 #[tokio::test]
293 async fn test_unsubscribe_nonexistent_returns_false() {
294 let subs = ResourceSubscriptions::new();
295 assert!(!subs.unsubscribe("lsp-diagnostics:///nonexistent.rs").await);
296 }
297
298 #[tokio::test]
299 async fn test_subscribe_cap_exceeded() {
300 let subs = ResourceSubscriptions::new();
301 for i in 0..MAX_SUBSCRIPTIONS {
302 subs.subscribe(format!("lsp-diagnostics:///file{i}.rs"))
303 .await
304 .unwrap();
305 }
306 let result = subs
307 .subscribe("lsp-diagnostics:///overflow.rs".to_string())
308 .await;
309 assert!(result.is_err());
310 }
311
312 #[tokio::test]
313 async fn test_snapshot() {
314 let subs = ResourceSubscriptions::new();
315 subs.subscribe("lsp-diagnostics:///a.rs".to_string())
316 .await
317 .unwrap();
318 subs.subscribe("lsp-diagnostics:///b.rs".to_string())
319 .await
320 .unwrap();
321 let mut snap = subs.snapshot().await;
322 snap.sort();
323 assert_eq!(snap, ["lsp-diagnostics:///a.rs", "lsp-diagnostics:///b.rs"]);
324 }
325}