mention.rs

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