1use agent_client_protocol as acp;
2use anyhow::{Context as _, Result, bail};
3use file_icons::FileIcons;
4use prompt_store::{PromptId, UserPromptId};
5use serde::{Deserialize, Serialize};
6use std::{
7 borrow::Cow,
8 fmt,
9 ops::RangeInclusive,
10 path::{Path, PathBuf},
11};
12use ui::{App, IconName, SharedString};
13use url::Url;
14use urlencoding::decode;
15use util::{ResultExt, paths::PathStyle};
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
18pub enum MentionUri {
19 File {
20 abs_path: PathBuf,
21 },
22 PastedImage,
23 Directory {
24 abs_path: PathBuf,
25 },
26 Symbol {
27 abs_path: PathBuf,
28 name: String,
29 line_range: RangeInclusive<u32>,
30 },
31 Thread {
32 id: acp::SessionId,
33 name: String,
34 },
35 TextThread {
36 path: PathBuf,
37 name: String,
38 },
39 Rule {
40 id: PromptId,
41 name: String,
42 },
43 Diagnostics {
44 #[serde(default = "default_include_errors")]
45 include_errors: bool,
46 #[serde(default)]
47 include_warnings: bool,
48 },
49 Selection {
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 abs_path: Option<PathBuf>,
52 line_range: RangeInclusive<u32>,
53 },
54 Fetch {
55 url: Url,
56 },
57 TerminalSelection {
58 line_count: u32,
59 },
60 GitDiff {
61 base_ref: String,
62 },
63 MergeConflict {
64 file_path: String,
65 },
66}
67
68impl MentionUri {
69 pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {
70 fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
71 let range = fragment.strip_prefix("L").unwrap_or(fragment);
72
73 let (start, end) = if let Some((start, end)) = range.split_once(":") {
74 (start, end)
75 } else if let Some((start, end)) = range.split_once("-") {
76 // Also handle L10-20 or L10-L20 format
77 (start, end.strip_prefix("L").unwrap_or(end))
78 } else {
79 // Single line number like L1872 - treat as a range of one line
80 (range, range)
81 };
82
83 let start_line = start
84 .parse::<u32>()
85 .context("Parsing line range start")?
86 .checked_sub(1)
87 .context("Line numbers should be 1-based")?;
88 let end_line = end
89 .parse::<u32>()
90 .context("Parsing line range end")?
91 .checked_sub(1)
92 .context("Line numbers should be 1-based")?;
93
94 Ok(start_line..=end_line)
95 }
96
97 let url = url::Url::parse(input)?;
98 let path = url.path();
99 match url.scheme() {
100 "file" => {
101 let normalized = if path_style.is_windows() {
102 path.trim_start_matches("/")
103 } else {
104 path
105 };
106 let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized));
107 let path = decoded.as_ref();
108
109 if let Some(fragment) = url.fragment() {
110 let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
111 if let Some(name) = single_query_param(&url, "symbol")? {
112 Ok(Self::Symbol {
113 name,
114 abs_path: path.into(),
115 line_range,
116 })
117 } else {
118 Ok(Self::Selection {
119 abs_path: Some(path.into()),
120 line_range,
121 })
122 }
123 } else if input.ends_with("/") {
124 Ok(Self::Directory {
125 abs_path: path.into(),
126 })
127 } else {
128 Ok(Self::File {
129 abs_path: path.into(),
130 })
131 }
132 }
133 "zed" => {
134 if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
135 let name = single_query_param(&url, "name")?.context("Missing thread name")?;
136 Ok(Self::Thread {
137 id: acp::SessionId::new(thread_id),
138 name,
139 })
140 } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
141 let name = single_query_param(&url, "name")?.context("Missing thread name")?;
142 Ok(Self::TextThread {
143 path: path.into(),
144 name,
145 })
146 } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
147 let name = single_query_param(&url, "name")?.context("Missing rule name")?;
148 let rule_id = UserPromptId(rule_id.parse()?);
149 Ok(Self::Rule {
150 id: rule_id.into(),
151 name,
152 })
153 } else if path == "/agent/diagnostics" {
154 let mut include_errors = default_include_errors();
155 let mut include_warnings = false;
156 for (key, value) in url.query_pairs() {
157 match key.as_ref() {
158 "include_warnings" => include_warnings = value == "true",
159 "include_errors" => include_errors = value == "true",
160 _ => bail!("invalid query parameter"),
161 }
162 }
163 Ok(Self::Diagnostics {
164 include_errors,
165 include_warnings,
166 })
167 } else if path.starts_with("/agent/pasted-image") {
168 Ok(Self::PastedImage)
169 } else if path.starts_with("/agent/untitled-buffer") {
170 let fragment = url
171 .fragment()
172 .context("Missing fragment for untitled buffer selection")?;
173 let line_range = parse_line_range(fragment)?;
174 Ok(Self::Selection {
175 abs_path: None,
176 line_range,
177 })
178 } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
179 let fragment = url
180 .fragment()
181 .context("Missing fragment for untitled buffer selection")?;
182 let line_range = parse_line_range(fragment)?;
183 let path =
184 single_query_param(&url, "path")?.context("Missing path for symbol")?;
185 Ok(Self::Symbol {
186 name: name.to_string(),
187 abs_path: path.into(),
188 line_range,
189 })
190 } else if path.starts_with("/agent/file") {
191 let path =
192 single_query_param(&url, "path")?.context("Missing path for file")?;
193 Ok(Self::File {
194 abs_path: path.into(),
195 })
196 } else if path.starts_with("/agent/directory") {
197 let path =
198 single_query_param(&url, "path")?.context("Missing path for directory")?;
199 Ok(Self::Directory {
200 abs_path: path.into(),
201 })
202 } else if path.starts_with("/agent/selection") {
203 let fragment = url.fragment().context("Missing fragment for selection")?;
204 let line_range = parse_line_range(fragment)?;
205 let path =
206 single_query_param(&url, "path")?.context("Missing path for selection")?;
207 Ok(Self::Selection {
208 abs_path: Some(path.into()),
209 line_range,
210 })
211 } else if path.starts_with("/agent/terminal-selection") {
212 let line_count = single_query_param(&url, "lines")?
213 .unwrap_or_else(|| "0".to_string())
214 .parse::<u32>()
215 .unwrap_or(0);
216 Ok(Self::TerminalSelection { line_count })
217 } else if path.starts_with("/agent/git-diff") {
218 let base_ref =
219 single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());
220 Ok(Self::GitDiff { base_ref })
221 } else if path.starts_with("/agent/merge-conflict") {
222 let file_path = single_query_param(&url, "path")?.unwrap_or_default();
223 Ok(Self::MergeConflict { file_path })
224 } else {
225 bail!("invalid zed url: {:?}", input);
226 }
227 }
228 "http" | "https" => Ok(MentionUri::Fetch { url }),
229 other => bail!("unrecognized scheme {:?}", other),
230 }
231 }
232
233 pub fn name(&self) -> String {
234 match self {
235 MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
236 .file_name()
237 .unwrap_or_default()
238 .to_string_lossy()
239 .into_owned(),
240 MentionUri::PastedImage => "Image".to_string(),
241 MentionUri::Symbol { name, .. } => name.clone(),
242 MentionUri::Thread { name, .. } => name.clone(),
243 MentionUri::TextThread { name, .. } => name.clone(),
244 MentionUri::Rule { name, .. } => name.clone(),
245 MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
246 MentionUri::TerminalSelection { line_count } => {
247 if *line_count == 1 {
248 "Terminal (1 line)".to_string()
249 } else {
250 format!("Terminal ({} lines)", line_count)
251 }
252 }
253 MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),
254 MentionUri::MergeConflict { file_path } => {
255 let name = Path::new(file_path)
256 .file_name()
257 .unwrap_or_default()
258 .to_string_lossy();
259 format!("Merge Conflict ({name})")
260 }
261 MentionUri::Selection {
262 abs_path: path,
263 line_range,
264 ..
265 } => selection_name(path.as_deref(), line_range),
266 MentionUri::Fetch { url } => url.to_string(),
267 }
268 }
269
270 pub fn tooltip_text(&self) -> Option<SharedString> {
271 match self {
272 MentionUri::File { abs_path } | MentionUri::Directory { abs_path } => {
273 Some(abs_path.to_string_lossy().into_owned().into())
274 }
275 MentionUri::Symbol {
276 abs_path,
277 line_range,
278 ..
279 } => Some(
280 format!(
281 "{}:{}-{}",
282 abs_path.display(),
283 line_range.start(),
284 line_range.end()
285 )
286 .into(),
287 ),
288 MentionUri::Selection {
289 abs_path: Some(path),
290 line_range,
291 ..
292 } => Some(
293 format!(
294 "{}:{}-{}",
295 path.display(),
296 line_range.start(),
297 line_range.end()
298 )
299 .into(),
300 ),
301 _ => None,
302 }
303 }
304
305 pub fn icon_path(&self, cx: &mut App) -> SharedString {
306 match self {
307 MentionUri::File { abs_path } => {
308 FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
309 }
310 MentionUri::PastedImage => IconName::Image.path().into(),
311 MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
312 .unwrap_or_else(|| IconName::Folder.path().into()),
313 MentionUri::Symbol { .. } => IconName::Code.path().into(),
314 MentionUri::Thread { .. } => IconName::Thread.path().into(),
315 MentionUri::TextThread { .. } => IconName::Thread.path().into(),
316 MentionUri::Rule { .. } => IconName::Reader.path().into(),
317 MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
318 MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),
319 MentionUri::Selection { .. } => IconName::Reader.path().into(),
320 MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
321 MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
322 MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),
323 }
324 }
325
326 pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
327 MentionLink(self)
328 }
329
330 pub fn to_uri(&self) -> Url {
331 match self {
332 MentionUri::File { abs_path } => {
333 let mut url = Url::parse("file:///").unwrap();
334 url.set_path(&abs_path.to_string_lossy());
335 url
336 }
337 MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
338 MentionUri::Directory { abs_path } => {
339 let mut url = Url::parse("file:///").unwrap();
340 let mut path_str = abs_path.to_string_lossy().into_owned();
341 if !path_str.ends_with('/') {
342 path_str.push('/');
343 }
344 url.set_path(&path_str);
345 url
346 }
347 MentionUri::Symbol {
348 abs_path,
349 name,
350 line_range,
351 } => {
352 let mut url = Url::parse("file:///").unwrap();
353 url.set_path(&abs_path.to_string_lossy());
354 url.query_pairs_mut().append_pair("symbol", name);
355 url.set_fragment(Some(&format!(
356 "L{}:{}",
357 line_range.start() + 1,
358 line_range.end() + 1
359 )));
360 url
361 }
362 MentionUri::Selection {
363 abs_path,
364 line_range,
365 } => {
366 let mut url = if let Some(path) = abs_path {
367 let mut url = Url::parse("file:///").unwrap();
368 url.set_path(&path.to_string_lossy());
369 url
370 } else {
371 let mut url = Url::parse("zed:///").unwrap();
372 url.set_path("/agent/untitled-buffer");
373 url
374 };
375 url.set_fragment(Some(&format!(
376 "L{}:{}",
377 line_range.start() + 1,
378 line_range.end() + 1
379 )));
380 url
381 }
382 MentionUri::Thread { name, id } => {
383 let mut url = Url::parse("zed:///").unwrap();
384 url.set_path(&format!("/agent/thread/{id}"));
385 url.query_pairs_mut().append_pair("name", name);
386 url
387 }
388 MentionUri::TextThread { path, name } => {
389 let mut url = Url::parse("zed:///").unwrap();
390 url.set_path(&format!(
391 "/agent/text-thread/{}",
392 path.to_string_lossy().trim_start_matches('/')
393 ));
394 url.query_pairs_mut().append_pair("name", name);
395 url
396 }
397 MentionUri::Rule { name, id } => {
398 let mut url = Url::parse("zed:///").unwrap();
399 url.set_path(&format!("/agent/rule/{id}"));
400 url.query_pairs_mut().append_pair("name", name);
401 url
402 }
403 MentionUri::Diagnostics {
404 include_errors,
405 include_warnings,
406 } => {
407 let mut url = Url::parse("zed:///").unwrap();
408 url.set_path("/agent/diagnostics");
409 if *include_warnings {
410 url.query_pairs_mut()
411 .append_pair("include_warnings", "true");
412 }
413 if !include_errors {
414 url.query_pairs_mut().append_pair("include_errors", "false");
415 }
416 url
417 }
418 MentionUri::Fetch { url } => url.clone(),
419 MentionUri::TerminalSelection { line_count } => {
420 let mut url = Url::parse("zed:///agent/terminal-selection").unwrap();
421 url.query_pairs_mut()
422 .append_pair("lines", &line_count.to_string());
423 url
424 }
425 MentionUri::GitDiff { base_ref } => {
426 let mut url = Url::parse("zed:///agent/git-diff").unwrap();
427 url.query_pairs_mut().append_pair("base", base_ref);
428 url
429 }
430 MentionUri::MergeConflict { file_path } => {
431 let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
432 url.query_pairs_mut().append_pair("path", file_path);
433 url
434 }
435 }
436 }
437}
438
439pub struct MentionLink<'a>(&'a MentionUri);
440
441impl fmt::Display for MentionLink<'_> {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
444 }
445}
446
447fn default_include_errors() -> bool {
448 true
449}
450
451fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
452 let pairs = url.query_pairs().collect::<Vec<_>>();
453 match pairs.as_slice() {
454 [] => Ok(None),
455 [(k, v)] => {
456 if k != name {
457 bail!("invalid query parameter")
458 }
459
460 Ok(Some(v.to_string()))
461 }
462 _ => bail!("too many query pairs"),
463 }
464}
465
466pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
467 format!(
468 "{} ({}:{})",
469 path.and_then(|path| path.file_name())
470 .unwrap_or("Untitled".as_ref())
471 .display(),
472 *line_range.start() + 1,
473 *line_range.end() + 1
474 )
475}
476
477#[cfg(test)]
478mod tests {
479 use util::{path, uri};
480
481 use super::*;
482
483 #[test]
484 fn test_parse_file_uri() {
485 let file_uri = uri!("file:///path/to/file.rs");
486 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
487 match &parsed {
488 MentionUri::File { abs_path } => {
489 assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
490 }
491 _ => panic!("Expected File variant"),
492 }
493 assert_eq!(parsed.to_uri().to_string(), file_uri);
494 }
495
496 #[test]
497 fn test_parse_directory_uri() {
498 let file_uri = uri!("file:///path/to/dir/");
499 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
500 match &parsed {
501 MentionUri::Directory { abs_path } => {
502 assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
503 }
504 _ => panic!("Expected Directory variant"),
505 }
506 assert_eq!(parsed.to_uri().to_string(), file_uri);
507 }
508
509 #[test]
510 fn test_directory_uri_inserts_trailing_slash() {
511 let uri = MentionUri::Directory {
512 abs_path: PathBuf::from(path!("/path/to/dir")),
513 };
514 let expected = uri!("file:///path/to/dir/");
515 assert_eq!(uri.to_uri().to_string(), expected);
516 }
517
518 #[test]
519 fn test_to_directory_uri_without_slash() {
520 let uri = MentionUri::Directory {
521 abs_path: PathBuf::from(path!("/path/to/dir/")),
522 };
523 let expected = uri!("file:///path/to/dir/");
524 assert_eq!(uri.to_uri().to_string(), expected);
525 }
526
527 #[test]
528 fn test_parse_symbol_uri() {
529 let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
530 let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
531 match &parsed {
532 MentionUri::Symbol {
533 abs_path: path,
534 name,
535 line_range,
536 } => {
537 assert_eq!(path, Path::new(path!("/path/to/file.rs")));
538 assert_eq!(name, "MySymbol");
539 assert_eq!(line_range.start(), &9);
540 assert_eq!(line_range.end(), &19);
541 }
542 _ => panic!("Expected Symbol variant"),
543 }
544 assert_eq!(parsed.to_uri().to_string(), symbol_uri);
545 }
546
547 #[test]
548 fn test_parse_selection_uri() {
549 let selection_uri = uri!("file:///path/to/file.rs#L5:15");
550 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
551 match &parsed {
552 MentionUri::Selection {
553 abs_path: path,
554 line_range,
555 } => {
556 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
557 assert_eq!(line_range.start(), &4);
558 assert_eq!(line_range.end(), &14);
559 }
560 _ => panic!("Expected Selection variant"),
561 }
562 assert_eq!(parsed.to_uri().to_string(), selection_uri);
563 }
564
565 #[test]
566 fn test_parse_file_uri_with_non_ascii() {
567 let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
568 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
569 match &parsed {
570 MentionUri::File { abs_path } => {
571 assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
572 }
573 _ => panic!("Expected File variant"),
574 }
575 assert_eq!(parsed.to_uri().to_string(), file_uri);
576 }
577
578 #[test]
579 fn test_parse_untitled_selection_uri() {
580 let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
581 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
582 match &parsed {
583 MentionUri::Selection {
584 abs_path: None,
585 line_range,
586 } => {
587 assert_eq!(line_range.start(), &0);
588 assert_eq!(line_range.end(), &9);
589 }
590 _ => panic!("Expected Selection variant without path"),
591 }
592 assert_eq!(parsed.to_uri().to_string(), selection_uri);
593 }
594
595 #[test]
596 fn test_parse_thread_uri() {
597 let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
598 let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
599 match &parsed {
600 MentionUri::Thread {
601 id: thread_id,
602 name,
603 } => {
604 assert_eq!(thread_id.to_string(), "session123");
605 assert_eq!(name, "Thread name");
606 }
607 _ => panic!("Expected Thread variant"),
608 }
609 assert_eq!(parsed.to_uri().to_string(), thread_uri);
610 }
611
612 #[test]
613 fn test_parse_rule_uri() {
614 let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
615 let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
616 match &parsed {
617 MentionUri::Rule { id, name } => {
618 assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
619 assert_eq!(name, "Some rule");
620 }
621 _ => panic!("Expected Rule variant"),
622 }
623 assert_eq!(parsed.to_uri().to_string(), rule_uri);
624 }
625
626 #[test]
627 fn test_parse_fetch_http_uri() {
628 let http_uri = "http://example.com/path?query=value#fragment";
629 let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
630 match &parsed {
631 MentionUri::Fetch { url } => {
632 assert_eq!(url.to_string(), http_uri);
633 }
634 _ => panic!("Expected Fetch variant"),
635 }
636 assert_eq!(parsed.to_uri().to_string(), http_uri);
637 }
638
639 #[test]
640 fn test_parse_fetch_https_uri() {
641 let https_uri = "https://example.com/api/endpoint";
642 let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
643 match &parsed {
644 MentionUri::Fetch { url } => {
645 assert_eq!(url.to_string(), https_uri);
646 }
647 _ => panic!("Expected Fetch variant"),
648 }
649 assert_eq!(parsed.to_uri().to_string(), https_uri);
650 }
651
652 #[test]
653 fn test_parse_diagnostics_uri() {
654 let uri = "zed:///agent/diagnostics?include_warnings=true";
655 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
656 match &parsed {
657 MentionUri::Diagnostics {
658 include_errors,
659 include_warnings,
660 } => {
661 assert!(include_errors);
662 assert!(include_warnings);
663 }
664 _ => panic!("Expected Diagnostics variant"),
665 }
666 assert_eq!(parsed.to_uri().to_string(), uri);
667 }
668
669 #[test]
670 fn test_parse_diagnostics_uri_warnings_only() {
671 let uri = "zed:///agent/diagnostics?include_warnings=true&include_errors=false";
672 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
673 match &parsed {
674 MentionUri::Diagnostics {
675 include_errors,
676 include_warnings,
677 } => {
678 assert!(!include_errors);
679 assert!(include_warnings);
680 }
681 _ => panic!("Expected Diagnostics variant"),
682 }
683 assert_eq!(parsed.to_uri().to_string(), uri);
684 }
685
686 #[test]
687 fn test_invalid_scheme() {
688 assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
689 assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
690 assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
691 }
692
693 #[test]
694 fn test_invalid_zed_path() {
695 assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
696 assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
697 }
698
699 #[test]
700 fn test_single_line_number() {
701 // https://github.com/zed-industries/zed/issues/46114
702 let uri = uri!("file:///path/to/file.rs#L1872");
703 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
704 match &parsed {
705 MentionUri::Selection {
706 abs_path: path,
707 line_range,
708 } => {
709 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
710 assert_eq!(line_range.start(), &1871);
711 assert_eq!(line_range.end(), &1871);
712 }
713 _ => panic!("Expected Selection variant"),
714 }
715 }
716
717 #[test]
718 fn test_dash_separated_line_range() {
719 let uri = uri!("file:///path/to/file.rs#L10-20");
720 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
721 match &parsed {
722 MentionUri::Selection {
723 abs_path: path,
724 line_range,
725 } => {
726 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
727 assert_eq!(line_range.start(), &9);
728 assert_eq!(line_range.end(), &19);
729 }
730 _ => panic!("Expected Selection variant"),
731 }
732
733 // Also test L10-L20 format
734 let uri = uri!("file:///path/to/file.rs#L10-L20");
735 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
736 match &parsed {
737 MentionUri::Selection {
738 abs_path: path,
739 line_range,
740 } => {
741 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
742 assert_eq!(line_range.start(), &9);
743 assert_eq!(line_range.end(), &19);
744 }
745 _ => panic!("Expected Selection variant"),
746 }
747 }
748
749 #[test]
750 fn test_parse_terminal_selection_uri() {
751 let terminal_uri = "zed:///agent/terminal-selection?lines=42";
752 let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
753 match &parsed {
754 MentionUri::TerminalSelection { line_count } => {
755 assert_eq!(*line_count, 42);
756 }
757 _ => panic!("Expected TerminalSelection variant"),
758 }
759 assert_eq!(parsed.to_uri().to_string(), terminal_uri);
760 assert_eq!(parsed.name(), "Terminal (42 lines)");
761
762 // Test single line
763 let single_line_uri = "zed:///agent/terminal-selection?lines=1";
764 let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
765 assert_eq!(parsed_single.name(), "Terminal (1 line)");
766 }
767}