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