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    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}