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    fmt,
  8    ops::RangeInclusive,
  9    path::{Path, PathBuf},
 10};
 11use ui::{App, IconName, SharedString};
 12use url::Url;
 13use util::paths::PathStyle;
 14
 15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
 16pub enum MentionUri {
 17    File {
 18        abs_path: PathBuf,
 19    },
 20    PastedImage,
 21    Directory {
 22        abs_path: PathBuf,
 23    },
 24    Symbol {
 25        abs_path: PathBuf,
 26        name: String,
 27        line_range: RangeInclusive<u32>,
 28    },
 29    Thread {
 30        id: acp::SessionId,
 31        name: String,
 32    },
 33    TextThread {
 34        path: PathBuf,
 35        name: String,
 36    },
 37    Rule {
 38        id: PromptId,
 39        name: String,
 40    },
 41    Selection {
 42        #[serde(default, skip_serializing_if = "Option::is_none")]
 43        abs_path: Option<PathBuf>,
 44        line_range: RangeInclusive<u32>,
 45    },
 46    Fetch {
 47        url: Url,
 48    },
 49}
 50
 51impl MentionUri {
 52    pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {
 53        fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
 54            let range = fragment
 55                .strip_prefix("L")
 56                .context("Line range must start with \"L\"")?;
 57            let (start, end) = range
 58                .split_once(":")
 59                .context("Line range must use colon as separator")?;
 60            let range = start
 61                .parse::<u32>()
 62                .context("Parsing line range start")?
 63                .checked_sub(1)
 64                .context("Line numbers should be 1-based")?
 65                ..=end
 66                    .parse::<u32>()
 67                    .context("Parsing line range end")?
 68                    .checked_sub(1)
 69                    .context("Line numbers should be 1-based")?;
 70            Ok(range)
 71        }
 72
 73        let url = url::Url::parse(input)?;
 74        let path = url.path();
 75        match url.scheme() {
 76            "file" => {
 77                let path = if path_style.is_windows() {
 78                    path.trim_start_matches("/")
 79                } else {
 80                    path
 81                };
 82
 83                if let Some(fragment) = url.fragment() {
 84                    let line_range = parse_line_range(fragment)?;
 85                    if let Some(name) = single_query_param(&url, "symbol")? {
 86                        Ok(Self::Symbol {
 87                            name,
 88                            abs_path: path.into(),
 89                            line_range,
 90                        })
 91                    } else {
 92                        Ok(Self::Selection {
 93                            abs_path: Some(path.into()),
 94                            line_range,
 95                        })
 96                    }
 97                } else if input.ends_with("/") {
 98                    Ok(Self::Directory {
 99                        abs_path: path.into(),
100                    })
101                } else {
102                    Ok(Self::File {
103                        abs_path: path.into(),
104                    })
105                }
106            }
107            "zed" => {
108                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
109                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
110                    Ok(Self::Thread {
111                        id: acp::SessionId(thread_id.into()),
112                        name,
113                    })
114                } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
115                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
116                    Ok(Self::TextThread {
117                        path: path.into(),
118                        name,
119                    })
120                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
121                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;
122                    let rule_id = UserPromptId(rule_id.parse()?);
123                    Ok(Self::Rule {
124                        id: rule_id.into(),
125                        name,
126                    })
127                } else if path.starts_with("/agent/pasted-image") {
128                    Ok(Self::PastedImage)
129                } else if path.starts_with("/agent/untitled-buffer") {
130                    let fragment = url
131                        .fragment()
132                        .context("Missing fragment for untitled buffer selection")?;
133                    let line_range = parse_line_range(fragment)?;
134                    Ok(Self::Selection {
135                        abs_path: None,
136                        line_range,
137                    })
138                } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
139                    let fragment = url
140                        .fragment()
141                        .context("Missing fragment for untitled buffer selection")?;
142                    let line_range = parse_line_range(fragment)?;
143                    let path =
144                        single_query_param(&url, "path")?.context("Missing path for symbol")?;
145                    Ok(Self::Symbol {
146                        name: name.to_string(),
147                        abs_path: path.into(),
148                        line_range,
149                    })
150                } else if path.starts_with("/agent/file") {
151                    let path =
152                        single_query_param(&url, "path")?.context("Missing path for file")?;
153                    Ok(Self::File {
154                        abs_path: path.into(),
155                    })
156                } else if path.starts_with("/agent/directory") {
157                    let path =
158                        single_query_param(&url, "path")?.context("Missing path for directory")?;
159                    Ok(Self::Directory {
160                        abs_path: path.into(),
161                    })
162                } else if path.starts_with("/agent/selection") {
163                    let fragment = url.fragment().context("Missing fragment for selection")?;
164                    let line_range = parse_line_range(fragment)?;
165                    let path =
166                        single_query_param(&url, "path")?.context("Missing path for selection")?;
167                    Ok(Self::Selection {
168                        abs_path: Some(path.into()),
169                        line_range,
170                    })
171                } else {
172                    bail!("invalid zed url: {:?}", input);
173                }
174            }
175            "http" | "https" => Ok(MentionUri::Fetch { url }),
176            other => bail!("unrecognized scheme {:?}", other),
177        }
178    }
179
180    pub fn name(&self) -> String {
181        match self {
182            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
183                .file_name()
184                .unwrap_or_default()
185                .to_string_lossy()
186                .into_owned(),
187            MentionUri::PastedImage => "Image".to_string(),
188            MentionUri::Symbol { name, .. } => name.clone(),
189            MentionUri::Thread { name, .. } => name.clone(),
190            MentionUri::TextThread { name, .. } => name.clone(),
191            MentionUri::Rule { name, .. } => name.clone(),
192            MentionUri::Selection {
193                abs_path: path,
194                line_range,
195                ..
196            } => selection_name(path.as_deref(), line_range),
197            MentionUri::Fetch { url } => url.to_string(),
198        }
199    }
200
201    pub fn icon_path(&self, cx: &mut App) -> SharedString {
202        match self {
203            MentionUri::File { abs_path } => {
204                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
205            }
206            MentionUri::PastedImage => IconName::Image.path().into(),
207            MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
208                .unwrap_or_else(|| IconName::Folder.path().into()),
209            MentionUri::Symbol { .. } => IconName::Code.path().into(),
210            MentionUri::Thread { .. } => IconName::Thread.path().into(),
211            MentionUri::TextThread { .. } => IconName::Thread.path().into(),
212            MentionUri::Rule { .. } => IconName::Reader.path().into(),
213            MentionUri::Selection { .. } => IconName::Reader.path().into(),
214            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
215        }
216    }
217
218    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
219        MentionLink(self)
220    }
221
222    pub fn to_uri(&self) -> Url {
223        match self {
224            MentionUri::File { abs_path } => {
225                let mut url = Url::parse("file:///").unwrap();
226                url.set_path(&abs_path.to_string_lossy());
227                url
228            }
229            MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
230            MentionUri::Directory { abs_path } => {
231                let mut url = Url::parse("file:///").unwrap();
232                url.set_path(&abs_path.to_string_lossy());
233                url
234            }
235            MentionUri::Symbol {
236                abs_path,
237                name,
238                line_range,
239            } => {
240                let mut url = Url::parse("file:///").unwrap();
241                url.set_path(&abs_path.to_string_lossy());
242                url.query_pairs_mut().append_pair("symbol", name);
243                url.set_fragment(Some(&format!(
244                    "L{}:{}",
245                    line_range.start() + 1,
246                    line_range.end() + 1
247                )));
248                url
249            }
250            MentionUri::Selection {
251                abs_path,
252                line_range,
253            } => {
254                let mut url = if let Some(path) = abs_path {
255                    let mut url = Url::parse("file:///").unwrap();
256                    url.set_path(&path.to_string_lossy());
257                    url
258                } else {
259                    let mut url = Url::parse("zed:///").unwrap();
260                    url.set_path("/agent/untitled-buffer");
261                    url
262                };
263                url.set_fragment(Some(&format!(
264                    "L{}:{}",
265                    line_range.start() + 1,
266                    line_range.end() + 1
267                )));
268                url
269            }
270            MentionUri::Thread { name, id } => {
271                let mut url = Url::parse("zed:///").unwrap();
272                url.set_path(&format!("/agent/thread/{id}"));
273                url.query_pairs_mut().append_pair("name", name);
274                url
275            }
276            MentionUri::TextThread { path, name } => {
277                let mut url = Url::parse("zed:///").unwrap();
278                url.set_path(&format!(
279                    "/agent/text-thread/{}",
280                    path.to_string_lossy().trim_start_matches('/')
281                ));
282                url.query_pairs_mut().append_pair("name", name);
283                url
284            }
285            MentionUri::Rule { name, id } => {
286                let mut url = Url::parse("zed:///").unwrap();
287                url.set_path(&format!("/agent/rule/{id}"));
288                url.query_pairs_mut().append_pair("name", name);
289                url
290            }
291            MentionUri::Fetch { url } => url.clone(),
292        }
293    }
294}
295
296pub struct MentionLink<'a>(&'a MentionUri);
297
298impl fmt::Display for MentionLink<'_> {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
301    }
302}
303
304fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
305    let pairs = url.query_pairs().collect::<Vec<_>>();
306    match pairs.as_slice() {
307        [] => Ok(None),
308        [(k, v)] => {
309            if k != name {
310                bail!("invalid query parameter")
311            }
312
313            Ok(Some(v.to_string()))
314        }
315        _ => bail!("too many query pairs"),
316    }
317}
318
319pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
320    format!(
321        "{} ({}:{})",
322        path.and_then(|path| path.file_name())
323            .unwrap_or("Untitled".as_ref())
324            .display(),
325        *line_range.start() + 1,
326        *line_range.end() + 1
327    )
328}
329
330#[cfg(test)]
331mod tests {
332    use util::{path, uri};
333
334    use super::*;
335
336    #[test]
337    fn test_parse_file_uri() {
338        let file_uri = uri!("file:///path/to/file.rs");
339        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
340        match &parsed {
341            MentionUri::File { abs_path } => {
342                assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
343            }
344            _ => panic!("Expected File variant"),
345        }
346        assert_eq!(parsed.to_uri().to_string(), file_uri);
347    }
348
349    #[test]
350    fn test_parse_directory_uri() {
351        let file_uri = uri!("file:///path/to/dir/");
352        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
353        match &parsed {
354            MentionUri::Directory { abs_path } => {
355                assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
356            }
357            _ => panic!("Expected Directory variant"),
358        }
359        assert_eq!(parsed.to_uri().to_string(), file_uri);
360    }
361
362    #[test]
363    fn test_to_directory_uri_without_slash() {
364        let uri = MentionUri::Directory {
365            abs_path: PathBuf::from(path!("/path/to/dir/")),
366        };
367        let expected = uri!("file:///path/to/dir/");
368        assert_eq!(uri.to_uri().to_string(), expected);
369    }
370
371    #[test]
372    fn test_parse_symbol_uri() {
373        let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
374        let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
375        match &parsed {
376            MentionUri::Symbol {
377                abs_path: path,
378                name,
379                line_range,
380            } => {
381                assert_eq!(path, Path::new(path!("/path/to/file.rs")));
382                assert_eq!(name, "MySymbol");
383                assert_eq!(line_range.start(), &9);
384                assert_eq!(line_range.end(), &19);
385            }
386            _ => panic!("Expected Symbol variant"),
387        }
388        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
389    }
390
391    #[test]
392    fn test_parse_selection_uri() {
393        let selection_uri = uri!("file:///path/to/file.rs#L5:15");
394        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
395        match &parsed {
396            MentionUri::Selection {
397                abs_path: path,
398                line_range,
399            } => {
400                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
401                assert_eq!(line_range.start(), &4);
402                assert_eq!(line_range.end(), &14);
403            }
404            _ => panic!("Expected Selection variant"),
405        }
406        assert_eq!(parsed.to_uri().to_string(), selection_uri);
407    }
408
409    #[test]
410    fn test_parse_untitled_selection_uri() {
411        let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
412        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
413        match &parsed {
414            MentionUri::Selection {
415                abs_path: None,
416                line_range,
417            } => {
418                assert_eq!(line_range.start(), &0);
419                assert_eq!(line_range.end(), &9);
420            }
421            _ => panic!("Expected Selection variant without path"),
422        }
423        assert_eq!(parsed.to_uri().to_string(), selection_uri);
424    }
425
426    #[test]
427    fn test_parse_thread_uri() {
428        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
429        let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
430        match &parsed {
431            MentionUri::Thread {
432                id: thread_id,
433                name,
434            } => {
435                assert_eq!(thread_id.to_string(), "session123");
436                assert_eq!(name, "Thread name");
437            }
438            _ => panic!("Expected Thread variant"),
439        }
440        assert_eq!(parsed.to_uri().to_string(), thread_uri);
441    }
442
443    #[test]
444    fn test_parse_rule_uri() {
445        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
446        let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
447        match &parsed {
448            MentionUri::Rule { id, name } => {
449                assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
450                assert_eq!(name, "Some rule");
451            }
452            _ => panic!("Expected Rule variant"),
453        }
454        assert_eq!(parsed.to_uri().to_string(), rule_uri);
455    }
456
457    #[test]
458    fn test_parse_fetch_http_uri() {
459        let http_uri = "http://example.com/path?query=value#fragment";
460        let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
461        match &parsed {
462            MentionUri::Fetch { url } => {
463                assert_eq!(url.to_string(), http_uri);
464            }
465            _ => panic!("Expected Fetch variant"),
466        }
467        assert_eq!(parsed.to_uri().to_string(), http_uri);
468    }
469
470    #[test]
471    fn test_parse_fetch_https_uri() {
472        let https_uri = "https://example.com/api/endpoint";
473        let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
474        match &parsed {
475            MentionUri::Fetch { url } => {
476                assert_eq!(url.to_string(), https_uri);
477            }
478            _ => panic!("Expected Fetch variant"),
479        }
480        assert_eq!(parsed.to_uri().to_string(), https_uri);
481    }
482
483    #[test]
484    fn test_invalid_scheme() {
485        assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
486        assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
487        assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
488    }
489
490    #[test]
491    fn test_invalid_zed_path() {
492        assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
493        assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
494    }
495
496    #[test]
497    fn test_invalid_line_range_format() {
498        // Missing L prefix
499        assert!(
500            MentionUri::parse(uri!("file:///path/to/file.rs#10:20"), PathStyle::local()).is_err()
501        );
502
503        // Missing colon separator
504        assert!(
505            MentionUri::parse(uri!("file:///path/to/file.rs#L1020"), PathStyle::local()).is_err()
506        );
507
508        // Invalid numbers
509        assert!(
510            MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc"), PathStyle::local()).is_err()
511        );
512        assert!(
513            MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20"), PathStyle::local()).is_err()
514        );
515    }
516
517    #[test]
518    fn test_invalid_query_parameters() {
519        // Invalid query parameter name
520        assert!(
521            MentionUri::parse(
522                uri!("file:///path/to/file.rs#L10:20?invalid=test"),
523                PathStyle::local()
524            )
525            .is_err()
526        );
527
528        // Too many query parameters
529        assert!(
530            MentionUri::parse(
531                uri!("file:///path/to/file.rs#L10:20?symbol=test&another=param"),
532                PathStyle::local()
533            )
534            .is_err()
535        );
536    }
537
538    #[test]
539    fn test_zero_based_line_numbers() {
540        // Test that 0-based line numbers are rejected (should be 1-based)
541        assert!(
542            MentionUri::parse(uri!("file:///path/to/file.rs#L0:10"), PathStyle::local()).is_err()
543        );
544        assert!(
545            MentionUri::parse(uri!("file:///path/to/file.rs#L1:0"), PathStyle::local()).is_err()
546        );
547        assert!(
548            MentionUri::parse(uri!("file:///path/to/file.rs#L0:0"), PathStyle::local()).is_err()
549        );
550    }
551}