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