Skip to main content

xurl_core/
uri.rs

1use std::str::FromStr;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use crate::error::{Result, XurlError};
7use crate::model::{ProviderKind, ThreadQuery};
8
9static SESSION_ID_RE: Lazy<Regex> = Lazy::new(|| {
10    Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
11        .expect("valid regex")
12});
13static AMP_SESSION_ID_RE: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(r"(?i)^t-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
15        .expect("valid regex")
16});
17static OPENCODE_SESSION_ID_RE: Lazy<Regex> =
18    Lazy::new(|| Regex::new(r"^ses_[0-9A-Za-z]+$").expect("valid regex"));
19static PI_SHORT_ENTRY_ID_RE: Lazy<Regex> =
20    Lazy::new(|| Regex::new(r"(?i)^[0-9a-f]{8}$").expect("valid regex"));
21
22pub fn is_uuid_session_id(input: &str) -> bool {
23    SESSION_ID_RE.is_match(input)
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum SkillsUri {
28    Local {
29        skill_name: String,
30    },
31    Github {
32        owner: String,
33        repo: String,
34        skill_path: Option<String>,
35    },
36}
37
38impl SkillsUri {
39    pub fn parse(input: &str) -> Result<Self> {
40        input.parse()
41    }
42
43    pub fn as_string(&self) -> String {
44        match self {
45            Self::Local { skill_name } => format!("skills://{skill_name}"),
46            Self::Github {
47                owner,
48                repo,
49                skill_path,
50            } => {
51                if let Some(skill_path) = skill_path {
52                    format!("skills://github.com/{owner}/{repo}/{skill_path}")
53                } else {
54                    format!("skills://github.com/{owner}/{repo}")
55                }
56            }
57        }
58    }
59}
60
61impl FromStr for SkillsUri {
62    type Err = XurlError;
63
64    fn from_str(input: &str) -> Result<Self> {
65        let target_with_query = input
66            .strip_prefix("skills://")
67            .ok_or_else(|| XurlError::InvalidSkillsUri(input.to_string()))?;
68
69        let (target, raw_query) = split_target_and_query(target_with_query);
70        if raw_query.is_some_and(|query| !query.is_empty()) {
71            return Err(XurlError::InvalidSkillsUri(format!(
72                "{input} (query parameters are not supported)"
73            )));
74        }
75
76        if target.is_empty() {
77            return Err(XurlError::InvalidSkillsUri(input.to_string()));
78        }
79
80        if !target.contains('/') {
81            validate_skills_segment(target, input)?;
82            return Ok(Self::Local {
83                skill_name: target.to_string(),
84            });
85        }
86
87        let mut segments = target.split('/');
88        let host = segments.next().unwrap_or_default();
89        if host.is_empty() {
90            return Err(XurlError::InvalidSkillsUri(input.to_string()));
91        }
92        if host != "github.com" {
93            return Err(XurlError::UnsupportedSkillsHost(host.to_string()));
94        }
95
96        let owner = segments
97            .next()
98            .ok_or_else(|| XurlError::InvalidSkillsUri(input.to_string()))?;
99        let repo = segments
100            .next()
101            .ok_or_else(|| XurlError::InvalidSkillsUri(input.to_string()))?;
102        validate_skills_segment(owner, input)?;
103        validate_skills_segment(repo, input)?;
104
105        let remaining = segments.collect::<Vec<_>>();
106        if remaining.iter().any(|segment| segment.is_empty()) {
107            return Err(XurlError::InvalidSkillsUri(input.to_string()));
108        }
109        for segment in &remaining {
110            validate_skills_segment(segment, input)?;
111        }
112
113        let skill_path = if remaining.is_empty() {
114            None
115        } else {
116            Some(remaining.join("/"))
117        };
118
119        Ok(Self::Github {
120            owner: owner.to_string(),
121            repo: repo.to_string(),
122            skill_path,
123        })
124    }
125}
126
127fn validate_skills_segment(segment: &str, input: &str) -> Result<()> {
128    if segment.is_empty()
129        || segment == "."
130        || segment == ".."
131        || segment.contains('\\')
132        || segment.contains(':')
133    {
134        return Err(XurlError::InvalidSkillsUri(input.to_string()));
135    }
136    Ok(())
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct AgentsUri {
141    pub provider: ProviderKind,
142    pub session_id: String,
143    pub agent_id: Option<String>,
144    pub query: Vec<(String, Option<String>)>,
145}
146
147impl AgentsUri {
148    pub fn parse(input: &str) -> Result<Self> {
149        input.parse()
150    }
151
152    pub fn is_collection(&self) -> bool {
153        self.session_id.is_empty() && self.agent_id.is_none()
154    }
155
156    pub fn require_session_id(&self) -> Result<&str> {
157        if self.session_id.is_empty() {
158            return Err(XurlError::InvalidMode(
159                "session id is required for this operation".to_string(),
160            ));
161        }
162        Ok(&self.session_id)
163    }
164
165    pub fn as_agents_string(&self) -> String {
166        if self.is_collection() {
167            return format!("agents://{}", self.provider);
168        }
169
170        match &self.agent_id {
171            Some(agent_id) => format!(
172                "agents://{}/{}/{}",
173                self.provider, self.session_id, agent_id
174            ),
175            None => format!("agents://{}/{}", self.provider, self.session_id),
176        }
177    }
178
179    pub fn as_string(&self) -> String {
180        if self.is_collection() {
181            return self.as_agents_string();
182        }
183
184        match &self.agent_id {
185            Some(agent_id) => format!("{}://{}/{}", self.provider, self.session_id, agent_id),
186            None => format!("{}://{}", self.provider, self.session_id),
187        }
188    }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct RoleUri {
193    pub provider: ProviderKind,
194    pub role: String,
195    pub query: Vec<(String, Option<String>)>,
196}
197
198impl RoleUri {
199    pub fn parse(input: &str) -> Result<Option<Self>> {
200        parse_role_uri(input)
201    }
202
203    pub fn as_agents_string(&self) -> String {
204        format!("agents://{}/{}", self.provider, self.role)
205    }
206}
207
208type ParsedTarget<'a> = (ProviderKind, &'a str, Option<String>, bool);
209
210fn parse_agents_target<'a>(target: &'a str, input: &str) -> Result<ParsedTarget<'a>> {
211    let mut segments = target.split('/');
212    let provider_scheme = segments
213        .next()
214        .ok_or_else(|| XurlError::InvalidUri(input.to_string()))?;
215    if provider_scheme.is_empty() {
216        return Err(XurlError::InvalidUri(input.to_string()));
217    }
218    let provider = parse_provider(provider_scheme)?;
219    let mut remaining = segments.collect::<Vec<_>>();
220    if remaining.iter().any(|segment| segment.is_empty()) {
221        return Err(XurlError::InvalidUri(input.to_string()));
222    }
223
224    if provider == ProviderKind::Codex
225        && remaining.len() >= 2
226        && remaining.first().copied() == Some("threads")
227    {
228        remaining.remove(0);
229    }
230
231    match remaining.as_slice() {
232        [] => Ok((provider, "", None, true)),
233        [main_id] => Ok((provider, *main_id, None, true)),
234        [main_id, agent_id] => Ok((provider, *main_id, Some((*agent_id).to_string()), true)),
235        _ => Err(XurlError::InvalidUri(input.to_string())),
236    }
237}
238
239fn parse_legacy_target<'a>(scheme: &str, target: &'a str, input: &str) -> Result<ParsedTarget<'a>> {
240    let provider = parse_provider(scheme)?;
241    let normalized_target = match provider {
242        ProviderKind::Amp => target,
243        ProviderKind::Codex => target.strip_prefix("threads/").unwrap_or(target),
244        ProviderKind::Claude | ProviderKind::Gemini | ProviderKind::Pi | ProviderKind::Opencode => {
245            target
246        }
247    };
248    let mut segments = normalized_target.split('/');
249    let main_id = segments.next().unwrap_or_default();
250    let agent_id = segments.next().map(str::to_string);
251
252    if main_id.is_empty()
253        || segments.next().is_some()
254        || agent_id.as_deref().is_some_and(str::is_empty)
255    {
256        return Err(XurlError::InvalidUri(input.to_string()));
257    }
258
259    Ok((provider, main_id, agent_id, false))
260}
261
262impl FromStr for AgentsUri {
263    type Err = XurlError;
264
265    fn from_str(input: &str) -> Result<Self> {
266        let (scheme, target_with_query) = input
267            .split_once("://")
268            .map_or((None, input), |(scheme, target)| (Some(scheme), target));
269        let (target, raw_query) = split_target_and_query(target_with_query);
270
271        let query = parse_query(raw_query, input)?;
272
273        let (provider, raw_id, raw_agent_id, allows_collection) = match scheme {
274            Some("agents") => parse_agents_target(target, input)?,
275            Some(scheme) => parse_legacy_target(scheme, target, input)?,
276            None => parse_agents_target(target, input)?,
277        };
278
279        if raw_id.is_empty() {
280            if !(allows_collection && raw_agent_id.is_none()) {
281                return Err(XurlError::InvalidUri(input.to_string()));
282            }
283
284            return Ok(Self {
285                provider,
286                session_id: String::new(),
287                agent_id: None,
288                query,
289            });
290        }
291
292        match provider {
293            ProviderKind::Amp if !AMP_SESSION_ID_RE.is_match(raw_id) => {
294                return Err(XurlError::InvalidSessionId(raw_id.to_string()));
295            }
296            ProviderKind::Codex
297            | ProviderKind::Claude
298            | ProviderKind::Gemini
299            | ProviderKind::Pi
300                if !is_uuid_session_id(raw_id) =>
301            {
302                return Err(XurlError::InvalidSessionId(raw_id.to_string()));
303            }
304            ProviderKind::Opencode if !OPENCODE_SESSION_ID_RE.is_match(raw_id) => {
305                return Err(XurlError::InvalidSessionId(raw_id.to_string()));
306            }
307            _ => {}
308        }
309
310        if provider == ProviderKind::Amp
311            && let Some(agent_id) = raw_agent_id.as_deref()
312            && !AMP_SESSION_ID_RE.is_match(agent_id)
313        {
314            return Err(XurlError::InvalidSessionId(agent_id.to_string()));
315        }
316
317        let session_id = match provider {
318            ProviderKind::Amp => format!("T-{}", raw_id[2..].to_ascii_lowercase()),
319            ProviderKind::Codex
320            | ProviderKind::Claude
321            | ProviderKind::Gemini
322            | ProviderKind::Pi => raw_id.to_ascii_lowercase(),
323            ProviderKind::Opencode => raw_id.to_string(),
324        };
325
326        let agent_id = raw_agent_id.map(|agent_id| {
327            if provider == ProviderKind::Amp && AMP_SESSION_ID_RE.is_match(&agent_id) {
328                format!("T-{}", agent_id[2..].to_ascii_lowercase())
329            } else if ((provider == ProviderKind::Codex || provider == ProviderKind::Gemini)
330                && SESSION_ID_RE.is_match(&agent_id))
331                || (provider == ProviderKind::Pi
332                    && (is_uuid_session_id(&agent_id) || PI_SHORT_ENTRY_ID_RE.is_match(&agent_id)))
333            {
334                agent_id.to_ascii_lowercase()
335            } else {
336                agent_id
337            }
338        });
339
340        if provider == ProviderKind::Opencode
341            && let Some(child_id) = agent_id.as_deref()
342            && !OPENCODE_SESSION_ID_RE.is_match(child_id)
343        {
344            return Err(XurlError::InvalidSessionId(child_id.to_string()));
345        }
346
347        Ok(Self {
348            provider,
349            session_id,
350            agent_id,
351            query,
352        })
353    }
354}
355
356fn split_target_and_query(input: &str) -> (&str, Option<&str>) {
357    if let Some((target, query)) = input.split_once('?') {
358        (target, Some(query))
359    } else {
360        (input, None)
361    }
362}
363
364fn parse_query(raw_query: Option<&str>, full_input: &str) -> Result<Vec<(String, Option<String>)>> {
365    let Some(raw_query) = raw_query else {
366        return Ok(Vec::new());
367    };
368
369    if raw_query.is_empty() {
370        return Ok(Vec::new());
371    }
372
373    let mut query = Vec::new();
374    for pair in raw_query.split('&') {
375        if pair.is_empty() {
376            continue;
377        }
378
379        let (raw_key, raw_value) = if let Some((key, value)) = pair.split_once('=') {
380            (key, Some(value))
381        } else {
382            (pair, None)
383        };
384
385        let key =
386            percent_decode(raw_key).ok_or_else(|| XurlError::InvalidUri(full_input.to_string()))?;
387        if key.is_empty() {
388            return Err(XurlError::InvalidUri(full_input.to_string()));
389        }
390
391        let value = raw_value
392            .map(|value| {
393                percent_decode(value).ok_or_else(|| XurlError::InvalidUri(full_input.to_string()))
394            })
395            .transpose()?;
396
397        query.push((key, value));
398    }
399
400    Ok(query)
401}
402
403fn percent_decode(input: &str) -> Option<String> {
404    let bytes = input.as_bytes();
405    let mut out = Vec::with_capacity(bytes.len());
406    let mut idx = 0;
407
408    while idx < bytes.len() {
409        if bytes[idx] == b'%' {
410            if idx + 2 >= bytes.len() {
411                return None;
412            }
413            let hi = hex_value(bytes[idx + 1])?;
414            let lo = hex_value(bytes[idx + 2])?;
415            out.push((hi << 4) | lo);
416            idx += 3;
417        } else {
418            out.push(bytes[idx]);
419            idx += 1;
420        }
421    }
422
423    String::from_utf8(out).ok()
424}
425
426fn hex_value(ch: u8) -> Option<u8> {
427    match ch {
428        b'0'..=b'9' => Some(ch - b'0'),
429        b'a'..=b'f' => Some(10 + ch - b'a'),
430        b'A'..=b'F' => Some(10 + ch - b'A'),
431        _ => None,
432    }
433}
434
435fn parse_provider(scheme: &str) -> Result<ProviderKind> {
436    match scheme {
437        "amp" => Ok(ProviderKind::Amp),
438        "codex" => Ok(ProviderKind::Codex),
439        "claude" => Ok(ProviderKind::Claude),
440        "gemini" => Ok(ProviderKind::Gemini),
441        "pi" => Ok(ProviderKind::Pi),
442        "opencode" => Ok(ProviderKind::Opencode),
443        _ => Err(XurlError::UnsupportedScheme(scheme.to_string())),
444    }
445}
446
447fn looks_like_session_id(provider: ProviderKind, token: &str) -> bool {
448    match provider {
449        ProviderKind::Amp => AMP_SESSION_ID_RE.is_match(token),
450        ProviderKind::Codex | ProviderKind::Claude | ProviderKind::Gemini | ProviderKind::Pi => {
451            is_uuid_session_id(token)
452        }
453        ProviderKind::Opencode => OPENCODE_SESSION_ID_RE.is_match(token),
454    }
455}
456
457pub fn parse_role_uri(input: &str) -> Result<Option<RoleUri>> {
458    let (scheme, target_with_query) = input
459        .split_once("://")
460        .map_or((None, input), |(scheme, target)| (Some(scheme), target));
461    let (target, raw_query) = split_target_and_query(target_with_query);
462    let query = parse_query(raw_query, input)?;
463
464    let (provider, raw_id, raw_agent_id, _) = match scheme {
465        Some("agents") => parse_agents_target(target, input)?,
466        Some(scheme) => parse_legacy_target(scheme, target, input)?,
467        None => parse_agents_target(target, input)?,
468    };
469
470    if raw_id.is_empty() || raw_agent_id.is_some() || looks_like_session_id(provider, raw_id) {
471        return Ok(None);
472    }
473
474    Ok(Some(RoleUri {
475        provider,
476        role: raw_id.to_string(),
477        query,
478    }))
479}
480
481fn parse_thread_query_pairs(
482    input: &str,
483    query_raw: &str,
484) -> Result<(Option<String>, usize, Vec<String>)> {
485    let mut q = None::<String>;
486    let mut limit = None::<usize>;
487    let mut ignored_params = Vec::<String>::new();
488
489    for pair in query_raw.split('&').filter(|pair| !pair.is_empty()) {
490        let (raw_key, raw_value) = pair.split_once('=').map_or((pair, ""), |parts| parts);
491        let key = percent_decode_component(raw_key)?;
492        let value = percent_decode_component(raw_value)?;
493
494        match key.as_str() {
495            "q" => {
496                let trimmed = value.trim();
497                if !trimmed.is_empty() {
498                    q = Some(trimmed.to_string());
499                }
500            }
501            "limit" => {
502                limit = Some(value.parse::<usize>().map_err(|_| {
503                    XurlError::InvalidUri(format!("{input} (invalid limit={value})"))
504                })?);
505            }
506            _ => {
507                if !ignored_params.iter().any(|existing| existing == &key) {
508                    ignored_params.push(key);
509                }
510            }
511        }
512    }
513
514    Ok((q, limit.unwrap_or(10), ignored_params))
515}
516
517pub fn parse_collection_query_uri(input: &str) -> Result<Option<ThreadQuery>> {
518    let target = if let Some(target) = input.strip_prefix("agents://") {
519        target
520    } else if input.contains("://") {
521        return Ok(None);
522    } else {
523        input
524    };
525
526    let (provider_part, query_raw) = target.split_once('?').map_or((target, ""), |parts| parts);
527    if provider_part.is_empty() || provider_part.contains('/') {
528        return Ok(None);
529    }
530
531    let provider = parse_provider(provider_part)?;
532    let (q, limit, ignored_params) = parse_thread_query_pairs(input, query_raw)?;
533
534    Ok(Some(ThreadQuery {
535        uri: input.to_string(),
536        provider,
537        role: None,
538        q,
539        limit,
540        ignored_params,
541    }))
542}
543
544pub fn parse_role_query_uri(input: &str) -> Result<Option<ThreadQuery>> {
545    let Some(role_uri) = parse_role_uri(input)? else {
546        return Ok(None);
547    };
548
549    let target = if let Some(target) = input.strip_prefix("agents://") {
550        target
551    } else if input.contains("://") {
552        input.split_once("://").map_or("", |(_, target)| target)
553    } else {
554        input
555    };
556    let (_, query_raw) = target.split_once('?').map_or((target, ""), |parts| parts);
557    let (q, limit, ignored_params) = parse_thread_query_pairs(input, query_raw)?;
558
559    Ok(Some(ThreadQuery {
560        uri: input.to_string(),
561        provider: role_uri.provider,
562        role: Some(role_uri.role),
563        q,
564        limit,
565        ignored_params,
566    }))
567}
568
569fn percent_decode_component(input: &str) -> Result<String> {
570    let mut output = Vec::with_capacity(input.len());
571    let bytes = input.as_bytes();
572    let mut idx = 0;
573    while idx < bytes.len() {
574        match bytes[idx] {
575            b'+' => {
576                output.push(b' ');
577                idx += 1;
578            }
579            b'%' => {
580                if idx + 2 >= bytes.len() {
581                    return Err(XurlError::InvalidUri(format!(
582                        "invalid percent encoding in query component: {input}"
583                    )));
584                }
585                let h1 = hex_nibble(bytes[idx + 1]).ok_or_else(|| {
586                    XurlError::InvalidUri(format!(
587                        "invalid percent encoding in query component: {input}"
588                    ))
589                })?;
590                let h2 = hex_nibble(bytes[idx + 2]).ok_or_else(|| {
591                    XurlError::InvalidUri(format!(
592                        "invalid percent encoding in query component: {input}"
593                    ))
594                })?;
595                output.push((h1 << 4) | h2);
596                idx += 3;
597            }
598            value => {
599                output.push(value);
600                idx += 1;
601            }
602        }
603    }
604
605    String::from_utf8(output).map_err(|_| {
606        XurlError::InvalidUri(format!(
607            "query component is not valid UTF-8 after percent decoding: {input}"
608        ))
609    })
610}
611
612fn hex_nibble(value: u8) -> Option<u8> {
613    match value {
614        b'0'..=b'9' => Some(value - b'0'),
615        b'a'..=b'f' => Some(value - b'a' + 10),
616        b'A'..=b'F' => Some(value - b'A' + 10),
617        _ => None,
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::{
624        AgentsUri, SkillsUri, parse_collection_query_uri, parse_role_query_uri, parse_role_uri,
625    };
626    use crate::model::ProviderKind;
627
628    #[test]
629    fn parse_local_skills_uri() {
630        let uri = SkillsUri::parse("skills://xurl").expect("parse should succeed");
631        assert_eq!(
632            uri,
633            SkillsUri::Local {
634                skill_name: "xurl".to_string(),
635            }
636        );
637        assert_eq!(uri.as_string(), "skills://xurl");
638    }
639
640    #[test]
641    fn parse_github_skills_uri() {
642        let uri = SkillsUri::parse("skills://github.com/Xuanwo/xurl").expect("parse should work");
643        assert_eq!(
644            uri,
645            SkillsUri::Github {
646                owner: "Xuanwo".to_string(),
647                repo: "xurl".to_string(),
648                skill_path: None,
649            }
650        );
651        assert_eq!(uri.as_string(), "skills://github.com/Xuanwo/xurl");
652    }
653
654    #[test]
655    fn parse_github_skills_uri_with_path() {
656        let uri = SkillsUri::parse("skills://github.com/Xuanwo/xurl/skills/xurl")
657            .expect("parse should work");
658        assert_eq!(
659            uri,
660            SkillsUri::Github {
661                owner: "Xuanwo".to_string(),
662                repo: "xurl".to_string(),
663                skill_path: Some("skills/xurl".to_string()),
664            }
665        );
666    }
667
668    #[test]
669    fn parse_rejects_skills_query_parameters() {
670        let err =
671            SkillsUri::parse("skills://github.com/Xuanwo/xurl?ref=main").expect_err("must fail");
672        assert!(format!("{err}").contains("query parameters are not supported"));
673    }
674
675    #[test]
676    fn parse_rejects_skills_unsupported_host() {
677        let err =
678            SkillsUri::parse("skills://gitlab.com/Xuanwo/xurl").expect_err("must reject host");
679        assert!(format!("{err}").contains("unsupported skills host"));
680    }
681
682    #[test]
683    fn parse_rejects_skills_path_traversal() {
684        let err = SkillsUri::parse("skills://github.com/Xuanwo/xurl/../evil")
685            .expect_err("must reject traversal");
686        assert!(format!("{err}").contains("invalid skills uri"));
687    }
688
689    #[test]
690    fn parse_valid_uri() {
691        let uri = AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592").expect("parse");
692        assert_eq!(uri.provider, ProviderKind::Codex);
693        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
694        assert_eq!(uri.agent_id, None);
695        assert!(uri.query.is_empty());
696    }
697
698    #[test]
699    fn parse_agents_collection_uri() {
700        let uri = AgentsUri::parse("agents://codex").expect("parse");
701        assert_eq!(uri.provider, ProviderKind::Codex);
702        assert!(uri.session_id.is_empty());
703        assert!(uri.is_collection());
704    }
705
706    #[test]
707    fn parse_collection_uri_without_agents_prefix() {
708        let uri = AgentsUri::parse("codex").expect("parse");
709        assert_eq!(uri.provider, ProviderKind::Codex);
710        assert!(uri.session_id.is_empty());
711        assert!(uri.is_collection());
712    }
713
714    #[test]
715    fn parse_agents_collection_with_query() {
716        let uri = AgentsUri::parse("agents://codex?workdir=%2Ftmp&flag").expect("parse");
717        assert_eq!(uri.provider, ProviderKind::Codex);
718        assert!(uri.session_id.is_empty());
719        assert_eq!(uri.query.len(), 2);
720        assert_eq!(
721            uri.query[0],
722            ("workdir".to_string(), Some("/tmp".to_string()))
723        );
724        assert_eq!(uri.query[1], ("flag".to_string(), None));
725    }
726
727    #[test]
728    fn parse_agents_uri_with_query_repeated_keys() {
729        let uri = AgentsUri::parse(
730            "agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592?add_dir=%2Fa&add_dir=%2Fb",
731        )
732        .expect("parse should succeed");
733        assert_eq!(uri.query.len(), 2);
734        assert_eq!(
735            uri.query[0],
736            ("add_dir".to_string(), Some("/a".to_string()))
737        );
738        assert_eq!(
739            uri.query[1],
740            ("add_dir".to_string(), Some("/b".to_string()))
741        );
742    }
743
744    #[test]
745    fn parse_rejects_invalid_query_percent_encoding() {
746        let err = AgentsUri::parse("agents://codex?workdir=%2").expect_err("must fail");
747        assert!(format!("{err}").contains("invalid uri"));
748    }
749
750    #[test]
751    fn parse_rejects_empty_query_key() {
752        let err = AgentsUri::parse("agents://codex?=value").expect_err("must fail");
753        assert!(format!("{err}").contains("invalid uri"));
754    }
755
756    #[test]
757    fn parse_valid_amp_uri() {
758        let uri = AgentsUri::parse("amp://T-019C0797-C402-7389-BD80-D785C98DF295").expect("parse");
759        assert_eq!(uri.provider, ProviderKind::Amp);
760        assert_eq!(uri.session_id, "T-019c0797-c402-7389-bd80-d785c98df295");
761        assert_eq!(uri.agent_id, None);
762    }
763
764    #[test]
765    fn parse_codex_deeplink_uri() {
766        let uri = AgentsUri::parse("codex://threads/019c871c-b1f9-7f60-9c4f-87ed09f13592")
767            .expect("parse should succeed");
768        assert_eq!(uri.provider, ProviderKind::Codex);
769        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
770        assert_eq!(uri.agent_id, None);
771    }
772
773    #[test]
774    fn parse_agents_uri() {
775        let uri = AgentsUri::parse("agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592")
776            .expect("parse should succeed");
777        assert_eq!(uri.provider, ProviderKind::Codex);
778        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
779        assert_eq!(uri.agent_id, None);
780    }
781
782    #[test]
783    fn parse_agents_uri_without_agents_prefix() {
784        let uri = AgentsUri::parse("codex/019c871c-b1f9-7f60-9c4f-87ed09f13592")
785            .expect("parse should succeed");
786        assert_eq!(uri.provider, ProviderKind::Codex);
787        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
788        assert_eq!(uri.agent_id, None);
789    }
790
791    #[test]
792    fn parse_agents_codex_deeplink_uri() {
793        let uri = AgentsUri::parse("agents://codex/threads/019c871c-b1f9-7f60-9c4f-87ed09f13592")
794            .expect("parse should succeed");
795        assert_eq!(uri.provider, ProviderKind::Codex);
796        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
797        assert_eq!(uri.agent_id, None);
798    }
799
800    #[test]
801    fn parse_codex_subagent_uri() {
802        let uri = AgentsUri::parse(
803            "codex://019c871c-b1f9-7f60-9c4f-87ed09f13592/019c87fb-38b9-7843-92b1-832f02598495",
804        )
805        .expect("parse should succeed");
806        assert_eq!(uri.provider, ProviderKind::Codex);
807        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
808        assert_eq!(
809            uri.agent_id,
810            Some("019c87fb-38b9-7843-92b1-832f02598495".to_string())
811        );
812    }
813
814    #[test]
815    fn parse_agents_subagent_uri_without_agents_prefix() {
816        let uri = AgentsUri::parse(
817            "codex/019c871c-b1f9-7f60-9c4f-87ed09f13592/019c87fb-38b9-7843-92b1-832f02598495",
818        )
819        .expect("parse should succeed");
820        assert_eq!(uri.provider, ProviderKind::Codex);
821        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
822        assert_eq!(
823            uri.agent_id,
824            Some("019c87fb-38b9-7843-92b1-832f02598495".to_string())
825        );
826    }
827
828    #[test]
829    fn parse_agents_codex_subagent_uri() {
830        let uri = AgentsUri::parse(
831            "agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592/019c87fb-38b9-7843-92b1-832f02598495",
832        )
833        .expect("parse should succeed");
834        assert_eq!(uri.provider, ProviderKind::Codex);
835        assert_eq!(uri.session_id, "019c871c-b1f9-7f60-9c4f-87ed09f13592");
836        assert_eq!(
837            uri.agent_id,
838            Some("019c87fb-38b9-7843-92b1-832f02598495".to_string())
839        );
840    }
841
842    #[test]
843    fn parse_amp_subagent_uri() {
844        let uri = AgentsUri::parse(
845            "amp://T-019C0797-C402-7389-BD80-D785C98DF295/T-1ABC0797-C402-7389-BD80-D785C98DF295",
846        )
847        .expect("parse should succeed");
848        assert_eq!(uri.provider, ProviderKind::Amp);
849        assert_eq!(uri.session_id, "T-019c0797-c402-7389-bd80-d785c98df295");
850        assert_eq!(
851            uri.agent_id,
852            Some("T-1abc0797-c402-7389-bd80-d785c98df295".to_string())
853        );
854    }
855
856    #[test]
857    fn parse_agents_amp_subagent_uri() {
858        let uri = AgentsUri::parse(
859            "agents://amp/T-019C0797-C402-7389-BD80-D785C98DF295/T-1ABC0797-C402-7389-BD80-D785C98DF295",
860        )
861        .expect("parse should succeed");
862        assert_eq!(uri.provider, ProviderKind::Amp);
863        assert_eq!(uri.session_id, "T-019c0797-c402-7389-bd80-d785c98df295");
864        assert_eq!(
865            uri.agent_id,
866            Some("T-1abc0797-c402-7389-bd80-d785c98df295".to_string())
867        );
868    }
869
870    #[test]
871    fn parse_claude_subagent_uri() {
872        let uri = AgentsUri::parse("claude://2823d1df-720a-4c31-ac55-ae8ba726721f/acompact-69d537")
873            .expect("parse should succeed");
874        assert_eq!(uri.provider, ProviderKind::Claude);
875        assert_eq!(uri.session_id, "2823d1df-720a-4c31-ac55-ae8ba726721f");
876        assert_eq!(uri.agent_id, Some("acompact-69d537".to_string()));
877    }
878
879    #[test]
880    fn parse_rejects_extra_path_segments() {
881        let err = AgentsUri::parse("codex://019c871c-b1f9-7f60-9c4f-87ed09f13592/a/b")
882            .expect_err("must reject nested path");
883        assert!(format!("{err}").contains("invalid uri"));
884    }
885
886    #[test]
887    fn parse_rejects_invalid_child_id_for_amp() {
888        let err = AgentsUri::parse("amp://T-019c0797-c402-7389-bd80-d785c98df295/child")
889            .expect_err("must reject amp path segment");
890        assert!(format!("{err}").contains("invalid session id"));
891    }
892
893    #[test]
894    fn parse_rejects_extra_path_segments_for_amp() {
895        let err = AgentsUri::parse(
896            "amp://T-019c0797-c402-7389-bd80-d785c98df295/T-1abc0797-c402-7389-bd80-d785c98df295/extra",
897        )
898        .expect_err("must reject nested path");
899        assert!(format!("{err}").contains("invalid uri"));
900    }
901
902    #[test]
903    fn parse_rejects_unsupported_scheme() {
904        let err = AgentsUri::parse("cursor://019c871c-b1f9-7f60-9c4f-87ed09f13592")
905            .expect_err("must reject unsupported scheme");
906        assert!(format!("{err}").contains("unsupported scheme"));
907    }
908
909    #[test]
910    fn parse_rejects_invalid_agents_provider() {
911        let err = AgentsUri::parse("agents://cursor/019c871c-b1f9-7f60-9c4f-87ed09f13592")
912            .expect_err("must reject provider");
913        assert!(format!("{err}").contains("unsupported scheme"));
914    }
915
916    #[test]
917    fn parse_rejects_invalid_session_id_for_codex() {
918        let err = AgentsUri::parse("codex://agent-a1b2c3").expect_err("must reject non-session id");
919        assert!(format!("{err}").contains("invalid session id"));
920    }
921
922    #[test]
923    fn parse_valid_opencode_uri() {
924        let uri = AgentsUri::parse("opencode://ses_43a90e3adffejRgrTdlJa48CtE")
925            .expect("parse should succeed");
926        assert_eq!(uri.provider, ProviderKind::Opencode);
927        assert_eq!(uri.session_id, "ses_43a90e3adffejRgrTdlJa48CtE");
928        assert_eq!(uri.agent_id, None);
929    }
930
931    #[test]
932    fn parse_valid_gemini_uri() {
933        let uri = AgentsUri::parse("gemini://29D207DB-CA7E-40BA-87F7-E14C9DE60613")
934            .expect("parse should succeed");
935        assert_eq!(uri.provider, ProviderKind::Gemini);
936        assert_eq!(uri.session_id, "29d207db-ca7e-40ba-87f7-e14c9de60613");
937        assert_eq!(uri.agent_id, None);
938    }
939
940    #[test]
941    fn parse_gemini_subagent_uri() {
942        let uri = AgentsUri::parse(
943            "gemini://29D207DB-CA7E-40BA-87F7-E14C9DE60613/2B112C8A-D80A-4CFF-9C8A-6F3E6FBAF7FB",
944        )
945        .expect("parse should succeed");
946        assert_eq!(uri.provider, ProviderKind::Gemini);
947        assert_eq!(uri.session_id, "29d207db-ca7e-40ba-87f7-e14c9de60613");
948        assert_eq!(
949            uri.agent_id,
950            Some("2b112c8a-d80a-4cff-9c8a-6f3e6fbaf7fb".to_string())
951        );
952    }
953
954    #[test]
955    fn parse_agents_gemini_subagent_uri() {
956        let uri = AgentsUri::parse(
957            "agents://gemini/29d207db-ca7e-40ba-87f7-e14c9de60613/2b112c8a-d80a-4cff-9c8a-6f3e6fbaf7fb",
958        )
959        .expect("parse should succeed");
960        assert_eq!(uri.provider, ProviderKind::Gemini);
961        assert_eq!(uri.session_id, "29d207db-ca7e-40ba-87f7-e14c9de60613");
962        assert_eq!(
963            uri.agent_id,
964            Some("2b112c8a-d80a-4cff-9c8a-6f3e6fbaf7fb".to_string())
965        );
966    }
967
968    #[test]
969    fn parse_valid_pi_uri() {
970        let uri = AgentsUri::parse("pi://12CB4C19-2774-4DE4-A0D0-9FA32FBAE29F")
971            .expect("parse should succeed");
972        assert_eq!(uri.provider, ProviderKind::Pi);
973        assert_eq!(uri.session_id, "12cb4c19-2774-4de4-a0d0-9fa32fbae29f");
974        assert_eq!(uri.agent_id, None);
975    }
976
977    #[test]
978    fn parse_valid_pi_entry_uri() {
979        let uri = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/1C130174")
980            .expect("parse should succeed");
981        assert_eq!(uri.provider, ProviderKind::Pi);
982        assert_eq!(uri.session_id, "12cb4c19-2774-4de4-a0d0-9fa32fbae29f");
983        assert_eq!(uri.agent_id, Some("1c130174".to_string()));
984    }
985
986    #[test]
987    fn parse_valid_pi_child_session_uri() {
988        let uri = AgentsUri::parse(
989            "pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/72B3A4A8-4F08-40AF-8D7F-8B2C77584E89",
990        )
991        .expect("parse should succeed");
992        assert_eq!(uri.provider, ProviderKind::Pi);
993        assert_eq!(uri.session_id, "12cb4c19-2774-4de4-a0d0-9fa32fbae29f");
994        assert_eq!(
995            uri.agent_id,
996            Some("72b3a4a8-4f08-40af-8d7f-8b2c77584e89".to_string())
997        );
998    }
999
1000    #[test]
1001    fn parse_rejects_nested_pi_path() {
1002        let err = AgentsUri::parse("pi://12cb4c19-2774-4de4-a0d0-9fa32fbae29f/a/b")
1003            .expect_err("must reject nested path");
1004        assert!(format!("{err}").contains("invalid uri"));
1005    }
1006
1007    #[test]
1008    fn parse_collection_query_uri_with_defaults() {
1009        let query =
1010            parse_collection_query_uri("agents://codex").expect("collection query parse must work");
1011        let query = query.expect("query should be present");
1012        assert_eq!(query.provider, ProviderKind::Codex);
1013        assert_eq!(query.role, None);
1014        assert_eq!(query.q, None);
1015        assert_eq!(query.limit, 10);
1016        assert!(query.ignored_params.is_empty());
1017    }
1018
1019    #[test]
1020    fn parse_collection_query_uri_with_q_and_limit() {
1021        let query = parse_collection_query_uri("agents://claude?q=spawn+agent&limit=7")
1022            .expect("collection query parse must work");
1023        let query = query.expect("query should be present");
1024        assert_eq!(query.provider, ProviderKind::Claude);
1025        assert_eq!(query.role, None);
1026        assert_eq!(query.q, Some("spawn agent".to_string()));
1027        assert_eq!(query.limit, 7);
1028    }
1029
1030    #[test]
1031    fn parse_collection_query_uri_without_agents_prefix() {
1032        let query = parse_collection_query_uri("claude?q=spawn+agent&limit=7")
1033            .expect("collection query parse must work");
1034        let query = query.expect("query should be present");
1035        assert_eq!(query.provider, ProviderKind::Claude);
1036        assert_eq!(query.role, None);
1037        assert_eq!(query.q, Some("spawn agent".to_string()));
1038        assert_eq!(query.limit, 7);
1039    }
1040
1041    #[test]
1042    fn parse_collection_query_uri_ignores_unknown_keys() {
1043        let query = parse_collection_query_uri("agents://pi?q=hello&foo=bar&foo=baz")
1044            .expect("collection query parse must work");
1045        let query = query.expect("query should be present");
1046        assert_eq!(query.provider, ProviderKind::Pi);
1047        assert_eq!(query.role, None);
1048        assert_eq!(query.ignored_params, vec!["foo".to_string()]);
1049    }
1050
1051    #[test]
1052    fn parse_collection_query_uri_rejects_invalid_limit() {
1053        let err = parse_collection_query_uri("agents://gemini?limit=abc")
1054            .expect_err("invalid limit should fail");
1055        assert!(format!("{err}").contains("invalid uri"));
1056    }
1057
1058    #[test]
1059    fn parse_collection_query_uri_is_none_for_thread_uri() {
1060        let query =
1061            parse_collection_query_uri("agents://codex/019c871c-b1f9-7f60-9c4f-87ed09f13592")
1062                .expect("parsing must succeed");
1063        assert_eq!(query, None);
1064    }
1065
1066    #[test]
1067    fn parse_collection_query_uri_is_none_for_thread_uri_without_agents_prefix() {
1068        let query = parse_collection_query_uri("codex/019c871c-b1f9-7f60-9c4f-87ed09f13592")
1069            .expect("parsing must succeed");
1070        assert_eq!(query, None);
1071    }
1072
1073    #[test]
1074    fn parse_role_uri_with_agents_prefix() {
1075        let role_uri = parse_role_uri("agents://codex/reviewer").expect("parse must succeed");
1076        let role_uri = role_uri.expect("role uri must exist");
1077        assert_eq!(role_uri.provider, ProviderKind::Codex);
1078        assert_eq!(role_uri.role, "reviewer");
1079    }
1080
1081    #[test]
1082    fn parse_role_uri_without_agents_prefix() {
1083        let role_uri = parse_role_uri("codex/reviewer").expect("parse must succeed");
1084        let role_uri = role_uri.expect("role uri must exist");
1085        assert_eq!(role_uri.provider, ProviderKind::Codex);
1086        assert_eq!(role_uri.role, "reviewer");
1087    }
1088
1089    #[test]
1090    fn parse_role_uri_returns_none_for_valid_session() {
1091        let role_uri = parse_role_uri("codex/019c871c-b1f9-7f60-9c4f-87ed09f13592")
1092            .expect("parse must succeed");
1093        assert_eq!(role_uri, None);
1094    }
1095
1096    #[test]
1097    fn parse_role_query_uri_with_q_and_limit() {
1098        let query = parse_role_query_uri("agents://codex/reviewer?q=spawn+agent&limit=3")
1099            .expect("role query parse must succeed");
1100        let query = query.expect("query must exist");
1101        assert_eq!(query.provider, ProviderKind::Codex);
1102        assert_eq!(query.role, Some("reviewer".to_string()));
1103        assert_eq!(query.q, Some("spawn agent".to_string()));
1104        assert_eq!(query.limit, 3);
1105    }
1106
1107    #[test]
1108    fn parse_role_query_uri_returns_none_for_collection() {
1109        let query = parse_role_query_uri("agents://codex").expect("parse must succeed");
1110        assert_eq!(query, None);
1111    }
1112}