oxicode_sdk/ports/inmem/
url_router.rs1use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14#[cfg(test)]
15use async_trait::async_trait;
16use parking_lot::RwLock;
17
18use crate::ports::{InternalUrlRouter, ProtocolHandler, ResolveContext, ResolvedUrl, SdkError};
19
20pub struct CompositeUrlRouter {
25 handlers: RwLock<HashMap<String, Arc<dyn ProtocolHandler>>>,
26}
27
28impl CompositeUrlRouter {
29 pub fn new() -> Self {
31 Self {
32 handlers: RwLock::new(HashMap::new()),
33 }
34 }
35
36 pub fn register(&self, handler: Arc<dyn ProtocolHandler>) {
39 self.handlers
40 .write()
41 .insert(handler.scheme().to_lowercase(), handler);
42 }
43
44 pub fn unregister(&self, scheme: &str) -> bool {
46 self.handlers
47 .write()
48 .remove(&scheme.to_lowercase())
49 .is_some()
50 }
51
52 pub fn can_resolve(&self, scheme: &str) -> bool {
54 self.handlers.read().contains_key(&scheme.to_lowercase())
55 }
56
57 fn parse_scheme(input: &str) -> Option<&str> {
60 let bytes = input.as_bytes();
62 let len = bytes.len();
63 if len < 3 {
64 return None;
65 }
66 if !bytes[0].is_ascii_lowercase() {
68 return None;
69 }
70 for i in 1..len {
72 match bytes[i] {
73 b':' => {
74 if i + 2 < len && bytes[i + 1] == b'/' && bytes[i + 2] == b'/' {
76 let scheme = &input[..i];
77 if scheme.eq_ignore_ascii_case("http")
79 || scheme.eq_ignore_ascii_case("https")
80 {
81 return None;
82 }
83 return Some(scheme);
84 }
85 return None;
86 }
87 b if b.is_ascii_lowercase()
88 || b.is_ascii_digit()
89 || b == b'+'
90 || b == b'.'
91 || b == b'-' => {}
92 _ => return None,
93 }
94 }
95 None
96 }
97
98 fn split_uri(uri: &str) -> Option<(&str, &str)> {
101 let scheme = Self::parse_scheme(uri)?;
102 let path = &uri[scheme.len() + 3..]; Some((scheme, path))
104 }
105}
106
107impl Default for CompositeUrlRouter {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl InternalUrlRouter for CompositeUrlRouter {
114 fn schemes(&self) -> &[&str] {
115 &[]
116 }
117
118 fn registered_schemes(&self) -> Vec<String> {
119 self.handlers.read().keys().cloned().collect()
120 }
121
122 fn resolve<'a>(
123 &'a self,
124 uri: &'a str,
125 ctx: &'a ResolveContext,
126 ) -> Pin<Box<dyn Future<Output = Result<ResolvedUrl, SdkError>> + Send + 'a>> {
127 Box::pin(async move {
128 let (scheme, path) = Self::split_uri(uri).ok_or_else(|| SdkError::UnknownScheme {
129 scheme: uri.to_string(),
130 })?;
131
132 let handler = self.handlers.read().get(scheme).cloned().ok_or_else(|| {
133 SdkError::UnknownScheme {
134 scheme: scheme.to_string(),
135 }
136 })?;
137
138 let mut resolved = handler.resolve(path, None, ctx).await?;
139 resolved.immutable = resolved.immutable || handler.immutable();
140
141 Ok(resolved)
142 })
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn test_parse_scheme_valid() {
152 assert_eq!(
153 CompositeUrlRouter::parse_scheme("issue://1428"),
154 Some("issue")
155 );
156 assert_eq!(
157 CompositeUrlRouter::parse_scheme("pr://owner/repo/1428"),
158 Some("pr")
159 );
160 assert_eq!(
161 CompositeUrlRouter::parse_scheme("agent://sub1/output"),
162 Some("agent")
163 );
164 assert_eq!(
165 CompositeUrlRouter::parse_scheme("skill://my-skill/SKILL.md"),
166 Some("skill")
167 );
168 assert_eq!(
169 CompositeUrlRouter::parse_scheme("memory://session-abc"),
170 Some("memory")
171 );
172 }
173
174 #[test]
175 fn test_parse_scheme_rejects_regular_paths() {
176 assert_eq!(CompositeUrlRouter::parse_scheme("src/main.rs"), None);
177 assert_eq!(CompositeUrlRouter::parse_scheme("/absolute/path"), None,);
178 assert_eq!(CompositeUrlRouter::parse_scheme("relative/path"), None);
179 }
180
181 #[test]
182 fn test_parse_scheme_rejects_http() {
183 assert_eq!(
185 CompositeUrlRouter::parse_scheme("https://example.com"),
186 None,
187 );
188 }
189
190 #[test]
191 fn test_split_uri() {
192 let (scheme, path) = CompositeUrlRouter::split_uri("pr://owner/repo/1428/diff/1").unwrap();
193 assert_eq!(scheme, "pr");
194 assert_eq!(path, "owner/repo/1428/diff/1");
195 }
196
197 #[test]
198 fn test_can_resolve() {
199 let router = CompositeUrlRouter::new();
200 assert!(!router.can_resolve("issue"));
201
202 struct DummyHandler;
204 #[async_trait]
205 impl ProtocolHandler for DummyHandler {
206 fn scheme(&self) -> &str {
207 "issue"
208 }
209 async fn resolve(
210 &self,
211 _: &str,
212 _: Option<&str>,
213 _: &ResolveContext,
214 ) -> Result<ResolvedUrl, SdkError> {
215 Ok(ResolvedUrl {
216 url: "issue://1".into(),
217 content: "test".into(),
218 content_type: "text/plain".into(),
219 size: None,
220 source_path: None,
221 notes: vec![],
222 immutable: false,
223 })
224 }
225 }
226
227 router.register(Arc::new(DummyHandler));
228 assert!(router.can_resolve("issue"));
229 assert!(!router.can_resolve("pr"));
230 }
231
232 #[test]
233 fn test_unregister() {
234 let router = CompositeUrlRouter::new();
235 assert!(!router.unregister("issue"));
236
237 struct DummyHandler;
238 #[async_trait]
239 impl ProtocolHandler for DummyHandler {
240 fn scheme(&self) -> &str {
241 "issue"
242 }
243 async fn resolve(
244 &self,
245 _: &str,
246 _: Option<&str>,
247 _: &ResolveContext,
248 ) -> Result<ResolvedUrl, SdkError> {
249 Ok(ResolvedUrl {
250 url: "issue://1".into(),
251 content: "test".into(),
252 content_type: "text/plain".into(),
253 size: None,
254 source_path: None,
255 notes: vec![],
256 immutable: false,
257 })
258 }
259 }
260
261 router.register(Arc::new(DummyHandler));
262 assert!(router.unregister("issue"));
263 assert!(!router.can_resolve("issue"));
264 }
265}