mention.rs

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