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