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