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