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}