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