Skip to main content

oxicode_sdk/ports/inmem/
url_router.rs

1//! Composite URL router — a multi-handler [`InternalUrlRouter`] implementation.
2//!
3//! Products register one [`ProtocolHandler`] per scheme and the router dispatches
4//! by extracting the scheme from the URI. A no-handler state returns
5//! [`SdkError::UnknownScheme`].
6//!
7//! Ported from omp `packages/coding-agent/src/internal-urls/router.ts`.
8
9use 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
20/// Multi-handler [`InternalUrlRouter`] that dispatches by URI scheme.
21///
22/// Thread-safe registration with internal [`RwLock`] — handlers can be
23/// added or removed at runtime.
24pub struct CompositeUrlRouter {
25    handlers: RwLock<HashMap<String, Arc<dyn ProtocolHandler>>>,
26}
27
28impl CompositeUrlRouter {
29    /// Create an empty router.
30    pub fn new() -> Self {
31        Self {
32            handlers: RwLock::new(HashMap::new()),
33        }
34    }
35
36    /// Register a handler for its scheme. Replaces any existing handler for
37    /// that scheme.
38    pub fn register(&self, handler: Arc<dyn ProtocolHandler>) {
39        self.handlers
40            .write()
41            .insert(handler.scheme().to_lowercase(), handler);
42    }
43
44    /// Remove a handler by scheme. Returns `true` if a handler was removed.
45    pub fn unregister(&self, scheme: &str) -> bool {
46        self.handlers
47            .write()
48            .remove(&scheme.to_lowercase())
49            .is_some()
50    }
51
52    /// Check whether any handler is registered for the given scheme.
53    pub fn can_resolve(&self, scheme: &str) -> bool {
54        self.handlers.read().contains_key(&scheme.to_lowercase())
55    }
56
57    /// Parse the scheme from a raw URI. Returns `None` when the input does
58    /// not look like an internal URL.
59    fn parse_scheme(input: &str) -> Option<&str> {
60        // ^([a-z][a-z0-9+.-]*)://
61        let bytes = input.as_bytes();
62        let len = bytes.len();
63        if len < 3 {
64            return None;
65        }
66        // First char must be a lower-case letter.
67        if !bytes[0].is_ascii_lowercase() {
68            return None;
69        }
70        // Scan scheme characters.
71        for i in 1..len {
72            match bytes[i] {
73                b':' => {
74                    // Must be followed by //
75                    if i + 2 < len && bytes[i + 1] == b'/' && bytes[i + 2] == b'/' {
76                        let scheme = &input[..i];
77                        // http and https are web URLs, not internal.
78                        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    /// Split an internal URL into `(scheme, path)`. The `://` separator is
99    /// consumed.
100    fn split_uri(uri: &str) -> Option<(&str, &str)> {
101        let scheme = Self::parse_scheme(uri)?;
102        let path = &uri[scheme.len() + 3..]; // skip "://"
103        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        // http:// and https:// are not internal — they're web URLs.
184        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        // Register a dummy handler.
203        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}