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 url.set_path(&abs_path.to_string_lossy());
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::TextThread { path, name } => {
385 let mut url = Url::parse("zed:///").unwrap();
386 url.set_path(&format!(
387 "/agent/text-thread/{}",
388 path.to_string_lossy().trim_start_matches('/')
389 ));
390 url.query_pairs_mut().append_pair("name", name);
391 url
392 }
393 MentionUri::Rule { name, id } => {
394 let mut url = Url::parse("zed:///").unwrap();
395 url.set_path(&format!("/agent/rule/{id}"));
396 url.query_pairs_mut().append_pair("name", name);
397 url
398 }
399 MentionUri::Diagnostics {
400 include_errors,
401 include_warnings,
402 } => {
403 let mut url = Url::parse("zed:///").unwrap();
404 url.set_path("/agent/diagnostics");
405 if *include_warnings {
406 url.query_pairs_mut()
407 .append_pair("include_warnings", "true");
408 }
409 if !include_errors {
410 url.query_pairs_mut().append_pair("include_errors", "false");
411 }
412 url
413 }
414 MentionUri::Fetch { url } => url.clone(),
415 MentionUri::TerminalSelection { line_count } => {
416 let mut url = Url::parse("zed:///agent/terminal-selection").unwrap();
417 url.query_pairs_mut()
418 .append_pair("lines", &line_count.to_string());
419 url
420 }
421 MentionUri::GitDiff { base_ref } => {
422 let mut url = Url::parse("zed:///agent/git-diff").unwrap();
423 url.query_pairs_mut().append_pair("base", base_ref);
424 url
425 }
426 MentionUri::MergeConflict { file_path } => {
427 let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
428 url.query_pairs_mut().append_pair("path", file_path);
429 url
430 }
431 }
432 }
433}
434
435pub struct MentionLink<'a>(&'a MentionUri);
436
437impl fmt::Display for MentionLink<'_> {
438 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439 write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
440 }
441}
442
443fn default_include_errors() -> bool {
444 true
445}
446
447fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
448 let pairs = url.query_pairs().collect::<Vec<_>>();
449 match pairs.as_slice() {
450 [] => Ok(None),
451 [(k, v)] => {
452 if k != name {
453 bail!("invalid query parameter")
454 }
455
456 Ok(Some(v.to_string()))
457 }
458 _ => bail!("too many query pairs"),
459 }
460}
461
462pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
463 format!(
464 "{} ({}:{})",
465 path.and_then(|path| path.file_name())
466 .unwrap_or("Untitled".as_ref())
467 .display(),
468 *line_range.start() + 1,
469 *line_range.end() + 1
470 )
471}
472
473#[cfg(test)]
474mod tests {
475 use util::{path, uri};
476
477 use super::*;
478
479 #[test]
480 fn test_parse_file_uri() {
481 let file_uri = uri!("file:///path/to/file.rs");
482 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
483 match &parsed {
484 MentionUri::File { abs_path } => {
485 assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
486 }
487 _ => panic!("Expected File variant"),
488 }
489 assert_eq!(parsed.to_uri().to_string(), file_uri);
490 }
491
492 #[test]
493 fn test_parse_directory_uri() {
494 let file_uri = uri!("file:///path/to/dir/");
495 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
496 match &parsed {
497 MentionUri::Directory { abs_path } => {
498 assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
499 }
500 _ => panic!("Expected Directory variant"),
501 }
502 assert_eq!(parsed.to_uri().to_string(), file_uri);
503 }
504
505 #[test]
506 fn test_to_directory_uri_without_slash() {
507 let uri = MentionUri::Directory {
508 abs_path: PathBuf::from(path!("/path/to/dir/")),
509 };
510 let expected = uri!("file:///path/to/dir/");
511 assert_eq!(uri.to_uri().to_string(), expected);
512 }
513
514 #[test]
515 fn test_parse_symbol_uri() {
516 let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
517 let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
518 match &parsed {
519 MentionUri::Symbol {
520 abs_path: path,
521 name,
522 line_range,
523 } => {
524 assert_eq!(path, Path::new(path!("/path/to/file.rs")));
525 assert_eq!(name, "MySymbol");
526 assert_eq!(line_range.start(), &9);
527 assert_eq!(line_range.end(), &19);
528 }
529 _ => panic!("Expected Symbol variant"),
530 }
531 assert_eq!(parsed.to_uri().to_string(), symbol_uri);
532 }
533
534 #[test]
535 fn test_parse_selection_uri() {
536 let selection_uri = uri!("file:///path/to/file.rs#L5:15");
537 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
538 match &parsed {
539 MentionUri::Selection {
540 abs_path: path,
541 line_range,
542 } => {
543 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
544 assert_eq!(line_range.start(), &4);
545 assert_eq!(line_range.end(), &14);
546 }
547 _ => panic!("Expected Selection variant"),
548 }
549 assert_eq!(parsed.to_uri().to_string(), selection_uri);
550 }
551
552 #[test]
553 fn test_parse_file_uri_with_non_ascii() {
554 let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
555 let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
556 match &parsed {
557 MentionUri::File { abs_path } => {
558 assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
559 }
560 _ => panic!("Expected File variant"),
561 }
562 assert_eq!(parsed.to_uri().to_string(), file_uri);
563 }
564
565 #[test]
566 fn test_parse_untitled_selection_uri() {
567 let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
568 let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
569 match &parsed {
570 MentionUri::Selection {
571 abs_path: None,
572 line_range,
573 } => {
574 assert_eq!(line_range.start(), &0);
575 assert_eq!(line_range.end(), &9);
576 }
577 _ => panic!("Expected Selection variant without path"),
578 }
579 assert_eq!(parsed.to_uri().to_string(), selection_uri);
580 }
581
582 #[test]
583 fn test_parse_thread_uri() {
584 let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
585 let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
586 match &parsed {
587 MentionUri::Thread {
588 id: thread_id,
589 name,
590 } => {
591 assert_eq!(thread_id.to_string(), "session123");
592 assert_eq!(name, "Thread name");
593 }
594 _ => panic!("Expected Thread variant"),
595 }
596 assert_eq!(parsed.to_uri().to_string(), thread_uri);
597 }
598
599 #[test]
600 fn test_parse_rule_uri() {
601 let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
602 let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
603 match &parsed {
604 MentionUri::Rule { id, name } => {
605 assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
606 assert_eq!(name, "Some rule");
607 }
608 _ => panic!("Expected Rule variant"),
609 }
610 assert_eq!(parsed.to_uri().to_string(), rule_uri);
611 }
612
613 #[test]
614 fn test_parse_fetch_http_uri() {
615 let http_uri = "http://example.com/path?query=value#fragment";
616 let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
617 match &parsed {
618 MentionUri::Fetch { url } => {
619 assert_eq!(url.to_string(), http_uri);
620 }
621 _ => panic!("Expected Fetch variant"),
622 }
623 assert_eq!(parsed.to_uri().to_string(), http_uri);
624 }
625
626 #[test]
627 fn test_parse_fetch_https_uri() {
628 let https_uri = "https://example.com/api/endpoint";
629 let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
630 match &parsed {
631 MentionUri::Fetch { url } => {
632 assert_eq!(url.to_string(), https_uri);
633 }
634 _ => panic!("Expected Fetch variant"),
635 }
636 assert_eq!(parsed.to_uri().to_string(), https_uri);
637 }
638
639 #[test]
640 fn test_parse_diagnostics_uri() {
641 let uri = "zed:///agent/diagnostics?include_warnings=true";
642 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
643 match &parsed {
644 MentionUri::Diagnostics {
645 include_errors,
646 include_warnings,
647 } => {
648 assert!(include_errors);
649 assert!(include_warnings);
650 }
651 _ => panic!("Expected Diagnostics variant"),
652 }
653 assert_eq!(parsed.to_uri().to_string(), uri);
654 }
655
656 #[test]
657 fn test_parse_diagnostics_uri_warnings_only() {
658 let uri = "zed:///agent/diagnostics?include_warnings=true&include_errors=false";
659 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
660 match &parsed {
661 MentionUri::Diagnostics {
662 include_errors,
663 include_warnings,
664 } => {
665 assert!(!include_errors);
666 assert!(include_warnings);
667 }
668 _ => panic!("Expected Diagnostics variant"),
669 }
670 assert_eq!(parsed.to_uri().to_string(), uri);
671 }
672
673 #[test]
674 fn test_invalid_scheme() {
675 assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
676 assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
677 assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
678 }
679
680 #[test]
681 fn test_invalid_zed_path() {
682 assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
683 assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
684 }
685
686 #[test]
687 fn test_single_line_number() {
688 // https://github.com/zed-industries/zed/issues/46114
689 let uri = uri!("file:///path/to/file.rs#L1872");
690 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
691 match &parsed {
692 MentionUri::Selection {
693 abs_path: path,
694 line_range,
695 } => {
696 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
697 assert_eq!(line_range.start(), &1871);
698 assert_eq!(line_range.end(), &1871);
699 }
700 _ => panic!("Expected Selection variant"),
701 }
702 }
703
704 #[test]
705 fn test_dash_separated_line_range() {
706 let uri = uri!("file:///path/to/file.rs#L10-20");
707 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
708 match &parsed {
709 MentionUri::Selection {
710 abs_path: path,
711 line_range,
712 } => {
713 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
714 assert_eq!(line_range.start(), &9);
715 assert_eq!(line_range.end(), &19);
716 }
717 _ => panic!("Expected Selection variant"),
718 }
719
720 // Also test L10-L20 format
721 let uri = uri!("file:///path/to/file.rs#L10-L20");
722 let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
723 match &parsed {
724 MentionUri::Selection {
725 abs_path: path,
726 line_range,
727 } => {
728 assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
729 assert_eq!(line_range.start(), &9);
730 assert_eq!(line_range.end(), &19);
731 }
732 _ => panic!("Expected Selection variant"),
733 }
734 }
735
736 #[test]
737 fn test_parse_terminal_selection_uri() {
738 let terminal_uri = "zed:///agent/terminal-selection?lines=42";
739 let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
740 match &parsed {
741 MentionUri::TerminalSelection { line_count } => {
742 assert_eq!(*line_count, 42);
743 }
744 _ => panic!("Expected TerminalSelection variant"),
745 }
746 assert_eq!(parsed.to_uri().to_string(), terminal_uri);
747 assert_eq!(parsed.name(), "Terminal (42 lines)");
748
749 // Test single line
750 let single_line_uri = "zed:///agent/terminal-selection?lines=1";
751 let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
752 assert_eq!(parsed_single.name(), "Terminal (1 line)");
753 }
754}