mention.rs

  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}