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