Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:00:43.306Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

mention.rs

1774 lines · 66.1 KB · rust
1use agent_client_protocol::schema::v1 as acp;
2use anyhow::{Context as _, Result, bail};
3use file_icons::FileIcons;
4use serde::{Deserialize, Serialize};
5use std::{
6    borrow::Cow,
7    fmt,
8    ops::RangeInclusive,
9    path::{Path, PathBuf},
10};
11use ui::{App, IconName, SharedString};
12use url::Url;
13use urlencoding::decode;
14use util::{
15    ResultExt,
16    paths::{PathStyle, PathWithPosition, is_absolute},
17};
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
20pub enum MentionUri {
21    File {
22        abs_path: PathBuf,
23    },
24    PastedImage {
25        name: String,
26    },
27    Directory {
28        abs_path: PathBuf,
29    },
30    Symbol {
31        abs_path: PathBuf,
32        name: String,
33        line_range: RangeInclusive<u32>,
34    },
35    Thread {
36        id: acp::SessionId,
37        name: String,
38    },
39    /// Deprecated: kept so threads from before rules became skills still
40    /// deserialize. `id` (an opaque `prompt_store::PromptId`) is preserved
41    /// verbatim so re-saved threads stay loadable by older Zed versions.
42    Rule {
43        #[serde(default = "default_deprecated_rule_id")]
44        id: serde_json::Value,
45        name: String,
46    },
47    Diagnostics {
48        #[serde(default = "default_include_errors")]
49        include_errors: bool,
50        #[serde(default)]
51        include_warnings: bool,
52    },
53    Selection {
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        abs_path: Option<PathBuf>,
56        line_range: RangeInclusive<u32>,
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        column: Option<u32>,
59    },
60    Fetch {
61        url: Url,
62    },
63    TerminalSelection {
64        line_count: u32,
65    },
66    GitDiff {
67        base_ref: String,
68    },
69    MergeConflict {
70        file_path: String,
71    },
72    Skill {
73        name: String,
74        source: String,
75        skill_file_path: PathBuf,
76    },
77}
78
79impl MentionUri {
80    pub fn parse(input: &str, path_style: PathStyle) -> Result<Self> {
81        let input = input
82            .strip_prefix('`')
83            .and_then(|input| input.strip_suffix('`'))
84            .unwrap_or(input);
85
86        let parse_column =
87            |input: Option<String>| -> Option<u32> { input?.parse::<u32>().ok()?.checked_sub(1) };
88        let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> {
89            for (key, _) in url.query_pairs() {
90                if !allowed.contains(&key.as_ref()) {
91                    bail!("invalid query parameter")
92                }
93            }
94            Ok(())
95        };
96
97        if is_absolute(input, path_style) && !input.contains("://") {
98            return parse_absolute_path(input)
99                .with_context(|| format!("Invalid absolute path mention URI: {input}"));
100        }
101
102        let url = url::Url::parse(input)?;
103        let path = url.path();
104        match url.scheme() {
105            "file" => {
106                let trimmed = if path_style.is_windows() {
107                    path.trim_start_matches("/")
108                } else {
109                    path
110                };
111                let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed));
112                let normalized: Cow<str> = if path_style.is_windows() {
113                    match to_native_windows_path(&decoded) {
114                        Some(native) => Cow::Owned(native),
115                        None => decoded,
116                    }
117                } else {
118                    decoded
119                };
120                let path = normalized.as_ref();
121
122                if let Some(fragment) = url.fragment() {
123                    validate_query_params(&url, &["symbol", "column"])?;
124                    let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
125                    let column = parse_column(query_param(&url, "column"));
126                    if let Some(name) = query_param(&url, "symbol") {
127                        Ok(Self::Symbol {
128                            name,
129                            abs_path: path.into(),
130                            line_range,
131                        })
132                    } else {
133                        Ok(Self::Selection {
134                            abs_path: Some(path.into()),
135                            line_range,
136                            column,
137                        })
138                    }
139                } else if input.ends_with("/") {
140                    Ok(Self::Directory {
141                        abs_path: path.into(),
142                    })
143                } else {
144                    Ok(Self::File {
145                        abs_path: path.into(),
146                    })
147                }
148            }
149            "zed" => {
150                if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
151                    let name = single_query_param(&url, "name")?.context("Missing thread name")?;
152                    Ok(Self::Thread {
153                        id: acp::SessionId::new(thread_id),
154                        name,
155                    })
156                } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
157                    // Deprecated: parses legacy rule mentions.
158                    let name = single_query_param(&url, "name")?.context("Missing rule name")?;
159                    let id = if rule_id.is_empty() {
160                        default_deprecated_rule_id()
161                    } else {
162                        serde_json::json!({ "User": { "uuid": rule_id } })
163                    };
164                    Ok(Self::Rule { id, name })
165                } else if path == "/agent/diagnostics" {
166                    let mut include_errors = default_include_errors();
167                    let mut include_warnings = false;
168                    for (key, value) in url.query_pairs() {
169                        match key.as_ref() {
170                            "include_warnings" => include_warnings = value == "true",
171                            "include_errors" => include_errors = value == "true",
172                            _ => bail!("invalid query parameter"),
173                        }
174                    }
175                    Ok(Self::Diagnostics {
176                        include_errors,
177                        include_warnings,
178                    })
179                } else if path.starts_with("/agent/pasted-image") {
180                    let name =
181                        single_query_param(&url, "name")?.unwrap_or_else(|| "Image".to_string());
182                    Ok(Self::PastedImage { name })
183                } else if path.starts_with("/agent/untitled-buffer") {
184                    let fragment = url
185                        .fragment()
186                        .context("Missing fragment for untitled buffer selection")?;
187                    let line_range = parse_line_range(fragment)?;
188                    validate_query_params(&url, &["column"])?;
189                    Ok(Self::Selection {
190                        abs_path: None,
191                        line_range,
192                        column: parse_column(query_param(&url, "column")),
193                    })
194                } else if let Some(name) = path.strip_prefix("/agent/symbol/") {
195                    let fragment = url
196                        .fragment()
197                        .context("Missing fragment for untitled buffer selection")?;
198                    let line_range = parse_line_range(fragment)?;
199                    let path =
200                        single_query_param(&url, "path")?.context("Missing path for symbol")?;
201                    Ok(Self::Symbol {
202                        name: name.to_string(),
203                        abs_path: path.into(),
204                        line_range,
205                    })
206                } else if path.starts_with("/agent/file") {
207                    let path =
208                        single_query_param(&url, "path")?.context("Missing path for file")?;
209                    Ok(Self::File {
210                        abs_path: path.into(),
211                    })
212                } else if path.starts_with("/agent/directory") {
213                    let path =
214                        single_query_param(&url, "path")?.context("Missing path for directory")?;
215                    Ok(Self::Directory {
216                        abs_path: path.into(),
217                    })
218                } else if path.starts_with("/agent/selection") {
219                    validate_query_params(&url, &["path", "column"])?;
220                    let fragment = url.fragment().context("Missing fragment for selection")?;
221                    let line_range = parse_line_range(fragment)?;
222                    let column = parse_column(query_param(&url, "column"));
223                    let path = query_param(&url, "path").context("Missing path for selection")?;
224                    Ok(Self::Selection {
225                        abs_path: Some(path.into()),
226                        line_range,
227                        column,
228                    })
229                } else if path.starts_with("/agent/terminal-selection") {
230                    let line_count = single_query_param(&url, "lines")?
231                        .unwrap_or_else(|| "0".to_string())
232                        .parse::<u32>()
233                        .unwrap_or(0);
234                    Ok(Self::TerminalSelection { line_count })
235                } else if path.starts_with("/agent/git-diff") {
236                    let base_ref =
237                        single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string());
238                    Ok(Self::GitDiff { base_ref })
239                } else if path.starts_with("/agent/merge-conflict") {
240                    let file_path = single_query_param(&url, "path")?.unwrap_or_default();
241                    Ok(Self::MergeConflict { file_path })
242                } else if path.starts_with("/agent/skill") {
243                    let mut name = None;
244                    let mut source = None;
245                    let mut skill_file_path = None;
246
247                    for (key, value) in url.query_pairs() {
248                        match key.as_ref() {
249                            "name" => {
250                                if name.replace(value.to_string()).is_some() {
251                                    bail!("duplicate skill name query parameter");
252                                }
253                            }
254                            "source" => {
255                                if source.replace(value.to_string()).is_some() {
256                                    bail!("duplicate skill source query parameter");
257                                }
258                            }
259                            "path" => {
260                                if skill_file_path
261                                    .replace(PathBuf::from(value.to_string()))
262                                    .is_some()
263                                {
264                                    bail!("duplicate skill file path query parameter");
265                                }
266                            }
267                            _ => bail!("invalid query parameter"),
268                        }
269                    }
270
271                    Ok(Self::Skill {
272                        name: name.context("missing skill name")?,
273                        source: source.context("missing skill source")?,
274                        skill_file_path: skill_file_path.context("missing skill file path")?,
275                    })
276                } else {
277                    bail!("invalid zed url: {:?}", input);
278                }
279            }
280            "http" | "https" => Ok(MentionUri::Fetch { url }),
281            other => bail!("unrecognized scheme {:?}", other),
282        }
283    }
284
285    /// Parses a hyperlink target from agent-authored Markdown.
286    ///
287    /// Unlike [`MentionUri::parse`] — which stays strict so canonical mention
288    /// URIs round-trip verbatim — bare path targets are normalized first:
289    /// percent escapes are decoded (see [`decode_path_escapes`]) and
290    /// Windows-compatible spellings like `/C:/foo` or `/c/foo` become native
291    /// paths (see [`to_native_windows_path`]).
292    pub fn parse_hyperlink(input: &str, path_style: PathStyle) -> Result<Self> {
293        if let Some(target) = bare_path_target(input, path_style) {
294            return parse_hyperlink_path(target, path_style, DecodePercentEscapes::Yes)
295                .with_context(|| format!("Invalid hyperlink path target: {input}"));
296        }
297        Self::parse(input, path_style)
298    }
299
300    /// Returns the literal (un-decoded) interpretation of a bare-path
301    /// hyperlink target, for files whose names literally contain an escape
302    /// sequence (e.g. `a%20b.rs`). Returns `None` when this wouldn't differ
303    /// from [`MentionUri::parse_hyperlink`], including for URLs, whose
304    /// escapes are unambiguous.
305    pub fn parse_hyperlink_literal(input: &str, path_style: PathStyle) -> Option<Self> {
306        let target = bare_path_target(input, path_style)?;
307        let (path_input, _) = split_path_fragment(target);
308        if !matches!(decode_path_escapes(path_input), Cow::Owned(_)) {
309            return None;
310        }
311        parse_hyperlink_path(target, path_style, DecodePercentEscapes::No).ok()
312    }
313
314    /// The absolute path this mention refers to, if it refers to one.
315    pub fn abs_path(&self) -> Option<&Path> {
316        match self {
317            MentionUri::File { abs_path }
318            | MentionUri::Directory { abs_path }
319            | MentionUri::Symbol { abs_path, .. } => Some(abs_path),
320            MentionUri::Selection { abs_path, .. } => abs_path.as_deref(),
321            MentionUri::Skill {
322                skill_file_path, ..
323            } => Some(skill_file_path),
324            MentionUri::PastedImage { .. }
325            | MentionUri::Thread { .. }
326            | MentionUri::Rule { .. }
327            | MentionUri::Diagnostics { .. }
328            | MentionUri::Fetch { .. }
329            | MentionUri::TerminalSelection { .. }
330            | MentionUri::GitDiff { .. }
331            | MentionUri::MergeConflict { .. } => None,
332        }
333    }
334
335    pub fn name(&self) -> String {
336        match self {
337            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path
338                .file_name()
339                .unwrap_or_default()
340                .to_string_lossy()
341                .into_owned(),
342            MentionUri::PastedImage { name } => name.clone(),
343            MentionUri::Symbol { name, .. } => name.clone(),
344            MentionUri::Thread { name, .. } => name.clone(),
345            MentionUri::Rule { name, .. } => name.clone(),
346            MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
347            MentionUri::TerminalSelection { line_count } => {
348                if *line_count == 1 {
349                    "Terminal (1 line)".to_string()
350                } else {
351                    format!("Terminal ({} lines)", line_count)
352                }
353            }
354            MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref),
355            MentionUri::MergeConflict { file_path } => {
356                let name = Path::new(file_path)
357                    .file_name()
358                    .unwrap_or_default()
359                    .to_string_lossy();
360                format!("Merge Conflict ({name})")
361            }
362            MentionUri::Selection {
363                abs_path: path,
364                line_range,
365                ..
366            } => selection_name(path.as_deref(), line_range),
367            MentionUri::Fetch { url } => url.to_string(),
368            MentionUri::Skill { name, .. } => name.clone(),
369        }
370    }
371
372    /// Returns a label for this mention at the given disambiguation `detail`
373    /// level. `detail == 0` is the base name returned by [`Self::name`]; higher
374    /// levels include progressively more context (e.g. additional parent path
375    /// components for files, or the source for skills) until a fixed point is
376    /// reached. Intended to be driven by [`util::disambiguate::compute_disambiguation_details`].
377    pub fn disambiguated_name(&self, detail: usize) -> String {
378        if detail == 0 {
379            return self.name();
380        }
381
382        match self {
383            MentionUri::Skill { name, source, .. } => {
384                if source.is_empty() {
385                    // Must match `SkillSource::display_label()` in agent_skills.
386                    format!("{} (global)", name)
387                } else {
388                    format!("{} ({})", name, source)
389                }
390            }
391            MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => {
392                project::path_suffix(abs_path, detail)
393            }
394            _ => self.name(),
395        }
396    }
397
398    pub fn tooltip_text(&self) -> Option<SharedString> {
399        match self {
400            MentionUri::File { abs_path } | MentionUri::Directory { abs_path } => {
401                Some(abs_path.to_string_lossy().into_owned().into())
402            }
403            MentionUri::Symbol {
404                abs_path,
405                line_range,
406                ..
407            } => Some(
408                format!(
409                    "{}:{}-{}",
410                    abs_path.display(),
411                    line_range.start(),
412                    line_range.end()
413                )
414                .into(),
415            ),
416            MentionUri::Selection {
417                abs_path: Some(path),
418                line_range,
419                ..
420            } => Some(
421                format!(
422                    "{}:{}-{}",
423                    path.display(),
424                    line_range.start(),
425                    line_range.end()
426                )
427                .into(),
428            ),
429            MentionUri::Skill {
430                skill_file_path, ..
431            } => Some(skill_file_path.to_string_lossy().into_owned().into()),
432            _ => None,
433        }
434    }
435
436    pub fn icon_path(&self, cx: &mut App) -> SharedString {
437        match self {
438            MentionUri::File { abs_path } => {
439                FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
440            }
441            MentionUri::PastedImage { .. } => IconName::Image.path().into(),
442            MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
443                .unwrap_or_else(|| IconName::Folder.path().into()),
444            MentionUri::Symbol { .. } => IconName::Code.path().into(),
445            MentionUri::Thread { .. } => IconName::Thread.path().into(),
446            MentionUri::Rule { .. } => IconName::Reader.path().into(),
447            MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
448            MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),
449            MentionUri::Selection { .. } => IconName::Reader.path().into(),
450            MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
451            MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(),
452            MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(),
453            MentionUri::Skill { .. } => IconName::Sparkle.path().into(),
454        }
455    }
456
457    pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
458        MentionLink(self)
459    }
460
461    pub fn to_uri(&self) -> Url {
462        match self {
463            MentionUri::File { abs_path } => {
464                let mut url = Url::parse("file:///").unwrap();
465                url.set_path(&abs_path.to_string_lossy());
466                url
467            }
468            MentionUri::PastedImage { name } => {
469                let mut url = Url::parse("zed:///agent/pasted-image").unwrap();
470                url.query_pairs_mut().append_pair("name", name);
471                url
472            }
473            MentionUri::Directory { abs_path } => {
474                let mut url = Url::parse("file:///").unwrap();
475                let mut path = abs_path.to_string_lossy().into_owned();
476                if !path.ends_with('/') && !path.ends_with('\\') {
477                    path.push('/');
478                }
479                url.set_path(&path);
480                url
481            }
482            MentionUri::Symbol {
483                abs_path,
484                name,
485                line_range,
486                ..
487            } => {
488                let mut url = Url::parse("file:///").unwrap();
489                url.set_path(&abs_path.to_string_lossy());
490                url.query_pairs_mut().append_pair("symbol", name);
491                url.set_fragment(Some(&format!(
492                    "L{}:{}",
493                    line_range.start() + 1,
494                    line_range.end() + 1
495                )));
496                url
497            }
498            MentionUri::Selection {
499                abs_path,
500                line_range,
501                column,
502            } => {
503                let mut url = if let Some(path) = abs_path {
504                    let mut url = Url::parse("file:///").unwrap();
505                    url.set_path(&path.to_string_lossy());
506                    url
507                } else {
508                    let mut url = Url::parse("zed:///").unwrap();
509                    url.set_path("/agent/untitled-buffer");
510                    url
511                };
512                if let Some(column) = column {
513                    url.query_pairs_mut()
514                        .append_pair("column", &(column + 1).to_string());
515                }
516                url.set_fragment(Some(&format!(
517                    "L{}:{}",
518                    line_range.start() + 1,
519                    line_range.end() + 1
520                )));
521                url
522            }
523            MentionUri::Thread { name, id } => {
524                let mut url = Url::parse("zed:///").unwrap();
525                url.set_path(&format!("/agent/thread/{id}"));
526                url.query_pairs_mut().append_pair("name", name);
527                url
528            }
529            MentionUri::Rule { id, name } => {
530                let mut url = Url::parse("zed:///").unwrap();
531                let rule_id = id
532                    .get("User")
533                    .and_then(|user| user.get("uuid"))
534                    .and_then(|uuid| uuid.as_str())
535                    .unwrap_or_default();
536                url.set_path(&format!("/agent/rule/{rule_id}"));
537                url.query_pairs_mut().append_pair("name", name);
538                url
539            }
540            MentionUri::Diagnostics {
541                include_errors,
542                include_warnings,
543            } => {
544                let mut url = Url::parse("zed:///").unwrap();
545                url.set_path("/agent/diagnostics");
546                if *include_warnings {
547                    url.query_pairs_mut()
548                        .append_pair("include_warnings", "true");
549                }
550                if !include_errors {
551                    url.query_pairs_mut().append_pair("include_errors", "false");
552                }
553                url
554            }
555            MentionUri::Fetch { url } => url.clone(),
556            MentionUri::TerminalSelection { line_count } => {
557                let mut url = Url::parse("zed:///agent/terminal-selection").unwrap();
558                url.query_pairs_mut()
559                    .append_pair("lines", &line_count.to_string());
560                url
561            }
562            MentionUri::GitDiff { base_ref } => {
563                let mut url = Url::parse("zed:///agent/git-diff").unwrap();
564                url.query_pairs_mut().append_pair("base", base_ref);
565                url
566            }
567            MentionUri::MergeConflict { file_path } => {
568                let mut url = Url::parse("zed:///agent/merge-conflict").unwrap();
569                url.query_pairs_mut().append_pair("path", file_path);
570                url
571            }
572            MentionUri::Skill {
573                name,
574                source,
575                skill_file_path,
576            } => {
577                let mut url = Url::parse("zed:///").unwrap();
578                url.set_path("/agent/skill");
579                url.query_pairs_mut()
580                    .append_pair("name", name)
581                    .append_pair("source", source)
582                    .append_pair("path", &skill_file_path.to_string_lossy());
583                url
584            }
585        }
586    }
587}
588
589pub struct MentionLink<'a>(&'a MentionUri);
590
591impl fmt::Display for MentionLink<'_> {
592    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
593        write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
594    }
595}
596
597#[derive(Clone, Copy, PartialEq, Eq)]
598enum DecodePercentEscapes {
599    Yes,
600    No,
601}
602
603fn parse_line_range(fragment: &str) -> Result<RangeInclusive<u32>> {
604    let range = fragment.strip_prefix("L").unwrap_or(fragment);
605
606    let (start, end) = if let Some((start, end)) = range.split_once(":") {
607        (start, end)
608    } else if let Some((start, end)) = range.split_once("-") {
609        // Also handle L10-20 or L10-L20 format
610        (start, end.strip_prefix("L").unwrap_or(end))
611    } else {
612        // Single line number like L1872 - treat as a range of one line
613        (range, range)
614    };
615
616    let start_line = start
617        .parse::<u32>()
618        .context("Parsing line range start")?
619        .checked_sub(1)
620        .context("Line numbers should be 1-based")?;
621    let end_line = end
622        .parse::<u32>()
623        .context("Parsing line range end")?
624        .checked_sub(1)
625        .context("Line numbers should be 1-based")?;
626
627    Ok(start_line..=end_line)
628}
629
630/// Returns the mention target as a bare absolute path (not a URL), with the
631/// backticks agents sometimes add stripped.
632fn bare_path_target(input: &str, path_style: PathStyle) -> Option<&str> {
633    let input = input
634        .strip_prefix('`')
635        .and_then(|input| input.strip_suffix('`'))
636        .unwrap_or(input);
637    (is_absolute(input, path_style) && !input.contains("://")).then_some(input)
638}
639
640fn split_path_fragment(input: &str) -> (&str, Option<&str>) {
641    input
642        .split_once('#')
643        .map_or((input, None), |(path, fragment)| (path, Some(fragment)))
644}
645
646fn parse_absolute_path(input: &str) -> Result<MentionUri> {
647    let (path_input, fragment) = split_path_fragment(input);
648    absolute_path_mention(path_input, fragment)
649}
650
651/// Like [`parse_absolute_path`], but normalizes hyperlink spellings first.
652fn parse_hyperlink_path(
653    input: &str,
654    path_style: PathStyle,
655    decode_escapes: DecodePercentEscapes,
656) -> Result<MentionUri> {
657    let (path_input, fragment) = split_path_fragment(input);
658    let path_input = normalize_path_mention(path_input, path_style, decode_escapes);
659    absolute_path_mention(&path_input, fragment)
660}
661
662fn absolute_path_mention(path_input: &str, fragment: Option<&str>) -> Result<MentionUri> {
663    if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) {
664        return Ok(MentionUri::Selection {
665            abs_path: Some(path_input.into()),
666            line_range: fragment,
667            column: None,
668        });
669    }
670
671    let path_with_position = PathWithPosition::parse_str(path_input);
672    let abs_path = path_with_position.path;
673    if let Some(row) = path_with_position.row {
674        let line = row
675            .checked_sub(1)
676            .context("Line numbers should be 1-based")?;
677        Ok(MentionUri::Selection {
678            abs_path: Some(abs_path),
679            line_range: line..=line,
680            column: path_with_position
681                .column
682                .map(|column| column.saturating_sub(1)),
683        })
684    } else {
685        Ok(MentionUri::File { abs_path })
686    }
687}
688
689fn normalize_path_mention(
690    input: &str,
691    path_style: PathStyle,
692    decode_escapes: DecodePercentEscapes,
693) -> Cow<'_, str> {
694    let decoded = match decode_escapes {
695        DecodePercentEscapes::Yes => decode_path_escapes(input),
696        DecodePercentEscapes::No => Cow::Borrowed(input),
697    };
698    if !path_style.is_windows() {
699        return decoded;
700    }
701    match to_native_windows_path(&decoded) {
702        Some(native) => Cow::Owned(native),
703        None => decoded,
704    }
705}
706
707/// Decodes percent escapes in a path, leaving separator escapes (`%2F`,
708/// `%5C`) encoded so decoding can't change which directories the path
709/// traverses. Invalid sequences and non-UTF-8 results leave the input
710/// unchanged. Returns `Cow::Owned` iff decoding changed the input
711/// (`parse_hyperlink_literal` relies on this).
712pub fn decode_path_escapes(input: &str) -> Cow<'_, str> {
713    fn hex_digit(byte: u8) -> Option<u8> {
714        match byte {
715            b'0'..=b'9' => Some(byte - b'0'),
716            b'a'..=b'f' => Some(byte - b'a' + 10),
717            b'A'..=b'F' => Some(byte - b'A' + 10),
718            _ => None,
719        }
720    }
721
722    if !input.contains('%') {
723        return Cow::Borrowed(input);
724    }
725    let bytes = input.as_bytes();
726    let mut decoded = Vec::with_capacity(bytes.len());
727    let mut index = 0;
728    while index < bytes.len() {
729        if bytes[index] == b'%'
730            && let Some(high) = bytes.get(index + 1).copied().and_then(hex_digit)
731            && let Some(low) = bytes.get(index + 2).copied().and_then(hex_digit)
732        {
733            let byte = (high << 4) | low;
734            if byte != b'/' && byte != b'\\' {
735                decoded.push(byte);
736                index += 3;
737                continue;
738            }
739        }
740        decoded.push(bytes[index]);
741        index += 1;
742    }
743    if decoded == bytes {
744        return Cow::Borrowed(input);
745    }
746    match String::from_utf8(decoded) {
747        Ok(decoded) => Cow::Owned(decoded),
748        Err(_) => Cow::Borrowed(input),
749    }
750}
751
752/// Converts Windows-compatible path spellings into a native Windows path,
753/// normalizing separators to backslashes and drive letters to uppercase so
754/// parsed paths compare equal to worktree paths. Returns `None` when the
755/// input needs no changes.
756fn to_native_windows_path(path: &str) -> Option<String> {
757    fn join_drive(drive: char, rest: &str) -> String {
758        format!(
759            "{}:\\{}",
760            drive.to_ascii_uppercase(),
761            rest.replace('/', "\\")
762        )
763    }
764
765    if let Some(rest) = path.strip_prefix('/') {
766        // URL-style path with a leading slash before the drive: `/C:/foo`.
767        let mut chars = rest.chars();
768        if let (Some(drive), Some(':'), Some('/' | '\\')) =
769            (chars.next(), chars.next(), chars.next())
770            && drive.is_ascii_alphabetic()
771        {
772            return Some(join_drive(drive, chars.as_str()));
773        }
774
775        // MSYS/Git Bash style: `/c/foo`. Lowercase-only, since that's what
776        // those shells emit and uppercase risks misreading real directories.
777        let mut chars = rest.chars();
778        if let (Some(drive), Some('/' | '\\')) = (chars.next(), chars.next())
779            && drive.is_ascii_lowercase()
780        {
781            return Some(join_drive(drive, chars.as_str()));
782        }
783    }
784
785    // A native path with a drive prefix: uppercase the drive and normalize
786    // separators, e.g. `c:/foo` or `c:\foo`.
787    let mut chars = path.chars();
788    if let (Some(drive), Some(':')) = (chars.next(), chars.next())
789        && drive.is_ascii_alphabetic()
790    {
791        if drive.is_ascii_uppercase() && !path.contains('/') {
792            return None;
793        }
794        return Some(format!(
795            "{}:{}",
796            drive.to_ascii_uppercase(),
797            chars.as_str().replace('/', "\\")
798        ));
799    }
800
801    if path.contains('/') {
802        return Some(path.replace('/', "\\"));
803    }
804
805    None
806}
807
808fn default_include_errors() -> bool {
809    true
810}
811
812/// Placeholder rule `id` for legacy mentions missing one, shaped so older Zed
813/// versions can still deserialize it as a `prompt_store::PromptId`.
814fn default_deprecated_rule_id() -> serde_json::Value {
815    serde_json::json!({ "User": { "uuid": "00000000-0000-0000-0000-000000000000" } })
816}
817
818fn query_param(url: &Url, name: &'static str) -> Option<String> {
819    url.query_pairs()
820        .find_map(|(key, value)| (key == name).then(|| value.to_string()))
821}
822
823fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
824    let pairs = url.query_pairs().collect::<Vec<_>>();
825    match pairs.as_slice() {
826        [] => Ok(None),
827        [(k, v)] => {
828            if k != name {
829                bail!("invalid query parameter")
830            }
831
832            Ok(Some(v.to_string()))
833        }
834        _ => bail!("too many query pairs"),
835    }
836}
837
838pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) -> String {
839    format!(
840        "{} ({}:{})",
841        path.and_then(|path| path.file_name())
842            .unwrap_or("Untitled".as_ref())
843            .display(),
844        *line_range.start() + 1,
845        *line_range.end() + 1
846    )
847}
848
849/// Formats a 0-based, inclusive line range as a 1-based path suffix: `:5` for a
850/// single line or `:5-9` for a span. Used for `path:line` mentions in text.
851pub fn line_range_suffix(line_range: &RangeInclusive<u32>) -> String {
852    let start = *line_range.start() + 1;
853    let end = *line_range.end() + 1;
854    if start == end {
855        format!(":{start}")
856    } else {
857        format!(":{start}-{end}")
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use util::{path, uri};
864
865    use super::*;
866
867    #[test]
868    fn test_parse_file_uri() {
869        let file_uri = uri!("file:///path/to/file.rs");
870        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
871        match &parsed {
872            MentionUri::File { abs_path } => {
873                assert_eq!(abs_path, Path::new(path!("/path/to/file.rs")));
874            }
875            _ => panic!("Expected File variant"),
876        }
877        assert_eq!(parsed.to_uri().to_string(), file_uri);
878    }
879
880    #[test]
881    fn test_parse_directory_uri() {
882        let file_uri = uri!("file:///path/to/dir/");
883        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
884        match &parsed {
885            MentionUri::Directory { abs_path } => {
886                assert_eq!(abs_path, Path::new(path!("/path/to/dir/")));
887            }
888            _ => panic!("Expected Directory variant"),
889        }
890        assert_eq!(parsed.to_uri().to_string(), file_uri);
891    }
892
893    #[test]
894    fn test_parse_file_uris_use_native_separators_on_windows() {
895        let parsed = MentionUri::parse("file:///C:/path/to/file.rs", PathStyle::Windows).unwrap();
896        match parsed {
897            MentionUri::File { abs_path } => {
898                assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
899            }
900            other => panic!("Expected File variant, got {other:?}"),
901        }
902
903        let parsed = MentionUri::parse("file:///C:/path/to/dir/", PathStyle::Windows).unwrap();
904        match parsed {
905            MentionUri::Directory { abs_path } => {
906                assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\dir\\"));
907            }
908            other => panic!("Expected Directory variant, got {other:?}"),
909        }
910
911        let parsed = MentionUri::parse(
912            "file:///C:/path/to/file.rs?symbol=MySymbol#L10:20",
913            PathStyle::Windows,
914        )
915        .unwrap();
916        match parsed {
917            MentionUri::Symbol { abs_path, .. } => {
918                assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
919            }
920            other => panic!("Expected Symbol variant, got {other:?}"),
921        }
922
923        let parsed =
924            MentionUri::parse("file:///C:/path/to/file.rs#L5:15", PathStyle::Windows).unwrap();
925        match parsed {
926            MentionUri::Selection {
927                abs_path: Some(abs_path),
928                ..
929            } => {
930                assert_eq!(abs_path, PathBuf::from("C:\\path\\to\\file.rs"));
931            }
932            other => panic!("Expected Selection variant, got {other:?}"),
933        }
934    }
935
936    #[test]
937    fn test_parse_file_uri_with_spaces() {
938        let parsed =
939            MentionUri::parse("file:///C:/path%20with%20space/file.rs", PathStyle::Windows)
940                .unwrap();
941        match parsed {
942            MentionUri::File { abs_path } => {
943                assert_eq!(abs_path, PathBuf::from("C:\\path with space\\file.rs"));
944            }
945            other => panic!("Expected File variant, got {other:?}"),
946        }
947        assert_eq!(
948            MentionUri::File {
949                abs_path: PathBuf::from("C:\\path with space\\file.rs")
950            }
951            .to_uri()
952            .to_string(),
953            "file:///C:/path%20with%20space/file.rs"
954        );
955    }
956
957    #[test]
958    fn test_parse_windows_drive_path_with_leading_slash_and_line() {
959        let parsed = MentionUri::parse_hyperlink(
960            "/C:/Projects/Example Workspace/Cargo.toml:2",
961            PathStyle::Windows,
962        )
963        .unwrap();
964        match parsed {
965            MentionUri::Selection {
966                abs_path: Some(abs_path),
967                line_range,
968                ..
969            } => {
970                assert_eq!(
971                    abs_path,
972                    PathBuf::from("C:\\Projects\\Example Workspace\\Cargo.toml")
973                );
974                assert_eq!(line_range, 1..=1);
975            }
976            other => panic!("Expected Selection variant, got {other:?}"),
977        }
978    }
979
980    #[test]
981    fn test_parse_windows_path_with_percent_escaped_spaces_and_line() {
982        let parsed = MentionUri::parse_hyperlink(
983            "C:\\Projects\\Example%20Workspace\\path\\to\\filename.ext:42",
984            PathStyle::Windows,
985        )
986        .unwrap();
987        match parsed {
988            MentionUri::Selection {
989                abs_path: Some(abs_path),
990                line_range,
991                ..
992            } => {
993                assert_eq!(
994                    abs_path,
995                    PathBuf::from("C:\\Projects\\Example Workspace\\path\\to\\filename.ext")
996                );
997                assert_eq!(line_range, 41..=41);
998            }
999            other => panic!("Expected Selection variant, got {other:?}"),
1000        }
1001    }
1002
1003    #[test]
1004    fn test_parse_windows_compat_path_with_spaces() {
1005        let parsed = MentionUri::parse_hyperlink(
1006            "/c/Projects/Example Workspace/AGENTS.md",
1007            PathStyle::Windows,
1008        )
1009        .unwrap();
1010        match parsed {
1011            MentionUri::File { abs_path } => {
1012                assert_eq!(
1013                    abs_path,
1014                    PathBuf::from("C:\\Projects\\Example Workspace\\AGENTS.md")
1015                );
1016            }
1017            other => panic!("Expected File variant, got {other:?}"),
1018        }
1019    }
1020
1021    #[test]
1022    fn test_parse_windows_drive_path_with_leading_slash_and_fragment_line() {
1023        let parsed =
1024            MentionUri::parse_hyperlink("/C:/Projects/Cargo.toml#L4", PathStyle::Windows).unwrap();
1025        match parsed {
1026            MentionUri::Selection {
1027                abs_path: Some(abs_path),
1028                line_range,
1029                ..
1030            } => {
1031                assert_eq!(abs_path, PathBuf::from("C:\\Projects\\Cargo.toml"));
1032                assert_eq!(line_range, 3..=3);
1033            }
1034            other => panic!("Expected Selection variant, got {other:?}"),
1035        }
1036    }
1037
1038    #[test]
1039    fn test_windows_drive_path_with_leading_slash_round_trips() {
1040        let parsed = MentionUri::parse_hyperlink("/C:/dir/file.rs", PathStyle::Windows).unwrap();
1041        assert_eq!(
1042            parsed,
1043            MentionUri::File {
1044                abs_path: PathBuf::from("C:\\dir\\file.rs")
1045            }
1046        );
1047        let uri = parsed.to_uri().to_string();
1048        assert_eq!(uri, "file:///C:/dir/file.rs");
1049        assert_eq!(MentionUri::parse(&uri, PathStyle::Windows).unwrap(), parsed);
1050    }
1051
1052    #[test]
1053    fn test_parse_windows_unc_path() {
1054        let parsed =
1055            MentionUri::parse_hyperlink("//server/share/dir/file.rs", PathStyle::Windows).unwrap();
1056        match parsed {
1057            MentionUri::File { abs_path } => {
1058                assert_eq!(abs_path, PathBuf::from("\\\\server\\share\\dir\\file.rs"));
1059            }
1060            other => panic!("Expected File variant, got {other:?}"),
1061        }
1062    }
1063
1064    #[test]
1065    fn test_parse_windows_drive_letters_are_uppercased() {
1066        for input in [
1067            "file:///c:/foo/bar.rs",
1068            "/c:/foo/bar.rs",
1069            "/c/foo/bar.rs",
1070            "c:\\foo\\bar.rs",
1071            "c:/foo/bar.rs",
1072        ] {
1073            let parsed = MentionUri::parse_hyperlink(input, PathStyle::Windows).unwrap();
1074            assert_eq!(
1075                parsed,
1076                MentionUri::File {
1077                    abs_path: PathBuf::from("C:\\foo\\bar.rs")
1078                },
1079                "input: {input}"
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn test_msys_style_paths_require_lowercase_drive() {
1086        // Uppercase `/C/foo` is more likely a real directory than a drive.
1087        let parsed = MentionUri::parse_hyperlink("/C/Users/readme.md", PathStyle::Windows).unwrap();
1088        match parsed {
1089            MentionUri::File { abs_path } => {
1090                assert_eq!(abs_path, PathBuf::from("\\C\\Users\\readme.md"));
1091            }
1092            other => panic!("Expected File variant, got {other:?}"),
1093        }
1094    }
1095
1096    #[test]
1097    fn test_posix_paths_are_not_rewritten_as_windows_drives() {
1098        let parsed = MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Unix).unwrap();
1099        match parsed {
1100            MentionUri::File { abs_path } => {
1101                assert_eq!(abs_path, PathBuf::from("/c/Projects/AGENTS.md"));
1102            }
1103            other => panic!("Expected File variant, got {other:?}"),
1104        }
1105    }
1106
1107    #[test]
1108    fn test_hyperlink_percent_escapes_are_decoded() {
1109        let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Unix).unwrap();
1110        assert_eq!(
1111            parsed,
1112            MentionUri::File {
1113                abs_path: PathBuf::from("/tmp/a b.rs")
1114            }
1115        );
1116
1117        // Invalid escape sequences pass through unchanged.
1118        let parsed =
1119            MentionUri::parse_hyperlink("C:\\dir\\100%_done.txt", PathStyle::Windows).unwrap();
1120        assert_eq!(
1121            parsed,
1122            MentionUri::File {
1123                abs_path: PathBuf::from("C:\\dir\\100%_done.txt")
1124            }
1125        );
1126
1127        // Separator escapes stay encoded (no introduced path traversal).
1128        let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Unix).unwrap();
1129        assert_eq!(
1130            parsed,
1131            MentionUri::File {
1132                abs_path: PathBuf::from("/tmp/a%2Fb.rs")
1133            }
1134        );
1135        let parsed = MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Unix).unwrap();
1136        assert_eq!(
1137            parsed,
1138            MentionUri::File {
1139                abs_path: PathBuf::from("/tmp/..%2F..%2Fsecret")
1140            }
1141        );
1142    }
1143
1144    #[test]
1145    fn test_parse_keeps_bare_path_targets_verbatim() {
1146        let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Unix).unwrap();
1147        assert_eq!(
1148            parsed,
1149            MentionUri::File {
1150                abs_path: PathBuf::from("/tmp/a%20b.rs")
1151            }
1152        );
1153
1154        let parsed = MentionUri::parse("/c/Projects/AGENTS.md", PathStyle::Windows).unwrap();
1155        assert_eq!(
1156            parsed,
1157            MentionUri::File {
1158                abs_path: PathBuf::from("/c/Projects/AGENTS.md")
1159            }
1160        );
1161    }
1162
1163    #[test]
1164    fn test_parse_hyperlink_literal_keeps_percent_escapes() {
1165        let literal =
1166            MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Unix).unwrap();
1167        assert_eq!(
1168            literal,
1169            MentionUri::File {
1170                abs_path: PathBuf::from("/tmp/a%20b.rs")
1171            }
1172        );
1173
1174        // Line suffixes still parse.
1175        let literal =
1176            MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Unix).unwrap();
1177        assert_eq!(
1178            literal,
1179            MentionUri::Selection {
1180                abs_path: Some(PathBuf::from("/tmp/a%20b.rs")),
1181                line_range: 41..=41,
1182                column: None,
1183            }
1184        );
1185
1186        // Windows normalization still applies.
1187        let literal =
1188            MentionUri::parse_hyperlink_literal("/C:/dir/a%20b.rs", PathStyle::Windows).unwrap();
1189        assert_eq!(
1190            literal,
1191            MentionUri::File {
1192                abs_path: PathBuf::from("C:\\dir\\a%20b.rs")
1193            }
1194        );
1195    }
1196
1197    #[test]
1198    fn test_parse_hyperlink_literal_returns_none_when_unambiguous() {
1199        // No percent escapes: identical to `parse_hyperlink`.
1200        assert_eq!(
1201            MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Unix),
1202            None
1203        );
1204        // Invalid escape sequences are also left alone by `parse_hyperlink`.
1205        assert_eq!(
1206            MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Unix),
1207            None
1208        );
1209        // Separator escapes are never decoded, so they're not ambiguous.
1210        assert_eq!(
1211            MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Unix),
1212            None
1213        );
1214        // URLs are spec-encoded, not ambiguous.
1215        assert_eq!(
1216            MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Unix),
1217            None
1218        );
1219        // Relative paths are not bare-path mentions.
1220        assert_eq!(
1221            MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Unix),
1222            None
1223        );
1224    }
1225
1226    #[test]
1227    fn test_to_directory_uri_without_slash() {
1228        let uri = MentionUri::Directory {
1229            abs_path: PathBuf::from(path!("/path/to/dir/")),
1230        };
1231        let expected = uri!("file:///path/to/dir/");
1232        assert_eq!(uri.to_uri().to_string(), expected);
1233    }
1234
1235    #[test]
1236    fn test_directory_uri_round_trip_without_trailing_slash() {
1237        let uri = MentionUri::Directory {
1238            abs_path: PathBuf::from(path!("/path/to/dir")),
1239        };
1240        let serialized = uri.to_uri().to_string();
1241        assert!(serialized.ends_with('/'), "directory URI must end with /");
1242        let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap();
1243        assert!(
1244            matches!(parsed, MentionUri::Directory { .. }),
1245            "expected Directory variant, got {:?}",
1246            parsed
1247        );
1248    }
1249
1250    #[test]
1251    fn test_parse_symbol_uri() {
1252        let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");
1253        let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap();
1254        match &parsed {
1255            MentionUri::Symbol {
1256                abs_path: path,
1257                name,
1258                line_range,
1259                ..
1260            } => {
1261                assert_eq!(path, Path::new(path!("/path/to/file.rs")));
1262                assert_eq!(name, "MySymbol");
1263                assert_eq!(line_range.start(), &9);
1264                assert_eq!(line_range.end(), &19);
1265            }
1266            _ => panic!("Expected Symbol variant"),
1267        }
1268        assert_eq!(parsed.to_uri().to_string(), symbol_uri);
1269    }
1270
1271    #[test]
1272    fn test_parse_selection_uri() {
1273        let selection_uri = uri!("file:///path/to/file.rs#L5:15");
1274        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
1275        match &parsed {
1276            MentionUri::Selection {
1277                abs_path: path,
1278                line_range,
1279                ..
1280            } => {
1281                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
1282                assert_eq!(line_range.start(), &4);
1283                assert_eq!(line_range.end(), &14);
1284            }
1285            _ => panic!("Expected Selection variant"),
1286        }
1287        assert_eq!(parsed.to_uri().to_string(), selection_uri);
1288    }
1289
1290    #[test]
1291    fn test_parse_file_uri_with_non_ascii() {
1292        let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
1293        let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
1294        match &parsed {
1295            MentionUri::File { abs_path } => {
1296                assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
1297            }
1298            _ => panic!("Expected File variant"),
1299        }
1300        assert_eq!(parsed.to_uri().to_string(), file_uri);
1301    }
1302
1303    #[test]
1304    fn test_parse_untitled_selection_uri() {
1305        let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
1306        let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap();
1307        match &parsed {
1308            MentionUri::Selection {
1309                abs_path: None,
1310                line_range,
1311                ..
1312            } => {
1313                assert_eq!(line_range.start(), &0);
1314                assert_eq!(line_range.end(), &9);
1315            }
1316            _ => panic!("Expected Selection variant without path"),
1317        }
1318        assert_eq!(parsed.to_uri().to_string(), selection_uri);
1319    }
1320
1321    #[test]
1322    fn test_parse_thread_uri() {
1323        let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
1324        let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap();
1325        match &parsed {
1326            MentionUri::Thread {
1327                id: thread_id,
1328                name,
1329            } => {
1330                assert_eq!(thread_id.to_string(), "session123");
1331                assert_eq!(name, "Thread name");
1332            }
1333            _ => panic!("Expected Thread variant"),
1334        }
1335        assert_eq!(parsed.to_uri().to_string(), thread_uri);
1336    }
1337
1338    #[test]
1339    fn test_parse_legacy_rule_uri() {
1340        let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
1341        let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap();
1342        match &parsed {
1343            MentionUri::Rule { name, .. } => assert_eq!(name, "Some rule"),
1344            _ => panic!("Expected Rule variant"),
1345        }
1346        // The id round-trips through the URI.
1347        assert_eq!(parsed.to_uri().to_string(), rule_uri);
1348    }
1349
1350    #[test]
1351    fn test_legacy_rule_mention_preserves_id() {
1352        // The `id` older Zed versions require must survive a load + save.
1353        let json = r#"{"Rule":{"id":{"User":{"uuid":"d8694ff2-90d5-4b6f-be33-33c1763acd52"}},"name":"Some rule"}}"#;
1354        let parsed: MentionUri = serde_json::from_str(json).unwrap();
1355        match &parsed {
1356            MentionUri::Rule { name, .. } => assert_eq!(name, "Some rule"),
1357            _ => panic!("Expected Rule variant"),
1358        }
1359        let reserialized = serde_json::to_value(&parsed).unwrap();
1360        assert_eq!(
1361            reserialized["Rule"]["id"]["User"]["uuid"],
1362            "d8694ff2-90d5-4b6f-be33-33c1763acd52"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_legacy_rule_mention_without_id_gets_placeholder() {
1368        // A mention missing its id still serializes a valid id for older versions.
1369        let json = r#"{"Rule":{"name":"Some rule"}}"#;
1370        let parsed: MentionUri = serde_json::from_str(json).unwrap();
1371        let reserialized = serde_json::to_value(&parsed).unwrap();
1372        assert!(reserialized["Rule"]["id"]["User"]["uuid"].is_string());
1373    }
1374
1375    #[test]
1376    fn test_parse_skill_uri_round_trip() {
1377        let skill_uri = MentionUri::Skill {
1378            name: "rust-best-practices".to_string(),
1379            source: "my-personal-project".to_string(),
1380            skill_file_path: PathBuf::from(path!("/path/to/skills/rust-best-practices/SKILL.md")),
1381        };
1382
1383        let serialized = skill_uri.to_uri().to_string();
1384        let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap();
1385
1386        assert_eq!(parsed, skill_uri);
1387    }
1388
1389    #[test]
1390    fn test_parse_fetch_http_uri() {
1391        let http_uri = "http://example.com/path?query=value#fragment";
1392        let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap();
1393        match &parsed {
1394            MentionUri::Fetch { url } => {
1395                assert_eq!(url.to_string(), http_uri);
1396            }
1397            _ => panic!("Expected Fetch variant"),
1398        }
1399        assert_eq!(parsed.to_uri().to_string(), http_uri);
1400    }
1401
1402    #[test]
1403    fn test_parse_fetch_https_uri() {
1404        let https_uri = "https://example.com/api/endpoint";
1405        let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap();
1406        match &parsed {
1407            MentionUri::Fetch { url } => {
1408                assert_eq!(url.to_string(), https_uri);
1409            }
1410            _ => panic!("Expected Fetch variant"),
1411        }
1412        assert_eq!(parsed.to_uri().to_string(), https_uri);
1413    }
1414
1415    #[test]
1416    fn test_parse_diagnostics_uri() {
1417        let uri = "zed:///agent/diagnostics?include_warnings=true";
1418        let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
1419        match &parsed {
1420            MentionUri::Diagnostics {
1421                include_errors,
1422                include_warnings,
1423            } => {
1424                assert!(include_errors);
1425                assert!(include_warnings);
1426            }
1427            _ => panic!("Expected Diagnostics variant"),
1428        }
1429        assert_eq!(parsed.to_uri().to_string(), uri);
1430    }
1431
1432    #[test]
1433    fn test_parse_diagnostics_uri_warnings_only() {
1434        let uri = "zed:///agent/diagnostics?include_warnings=true&include_errors=false";
1435        let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
1436        match &parsed {
1437            MentionUri::Diagnostics {
1438                include_errors,
1439                include_warnings,
1440            } => {
1441                assert!(!include_errors);
1442                assert!(include_warnings);
1443            }
1444            _ => panic!("Expected Diagnostics variant"),
1445        }
1446        assert_eq!(parsed.to_uri().to_string(), uri);
1447    }
1448
1449    #[test]
1450    fn test_invalid_scheme() {
1451        assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err());
1452        assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err());
1453        assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err());
1454    }
1455
1456    #[test]
1457    fn test_invalid_zed_path() {
1458        assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err());
1459        assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err());
1460    }
1461
1462    #[test]
1463    fn test_parse_absolute_file_path() {
1464        let file_path = path!("/path/to/file.rs");
1465        let parsed = MentionUri::parse(file_path, PathStyle::local()).unwrap();
1466        match &parsed {
1467            MentionUri::File { abs_path } => {
1468                assert_eq!(abs_path, Path::new(file_path));
1469            }
1470            _ => panic!("Expected File variant"),
1471        }
1472    }
1473
1474    #[test]
1475    fn test_parse_absolute_file_path_with_row() {
1476        let file_path = "/path/to/file.rs:42";
1477        let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap();
1478        match &parsed {
1479            MentionUri::Selection {
1480                abs_path: path,
1481                line_range,
1482                ..
1483            } => {
1484                assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
1485                assert_eq!(line_range.start(), &41);
1486                assert_eq!(line_range.end(), &41);
1487            }
1488            _ => panic!("Expected Selection variant"),
1489        }
1490    }
1491
1492    #[test]
1493    fn test_parse_absolute_file_path_with_row_and_column() {
1494        let file_path = "/path/to/file.rs:42:5";
1495        let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap();
1496        match &parsed {
1497            MentionUri::Selection {
1498                abs_path: path,
1499                line_range,
1500                column,
1501            } => {
1502                assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
1503                assert_eq!(line_range.start(), &41);
1504                assert_eq!(line_range.end(), &41);
1505                assert_eq!(column, &Some(4));
1506
1507                let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Unix)
1508                    .expect("selection URI with column should parse");
1509                assert_eq!(parsed_again, parsed.clone());
1510            }
1511            _ => panic!("Expected Selection variant"),
1512        }
1513    }
1514
1515    #[test]
1516    fn test_parse_absolute_file_path_with_fragment_line() {
1517        let file_path = "/path/to/file.rs#L42";
1518        let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap();
1519        match &parsed {
1520            MentionUri::Selection {
1521                abs_path: path,
1522                line_range,
1523                ..
1524            } => {
1525                assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
1526                assert_eq!(line_range.start(), &41);
1527                assert_eq!(line_range.end(), &41);
1528            }
1529            _ => panic!("Expected Selection variant"),
1530        }
1531    }
1532
1533    #[test]
1534    fn test_parse_absolute_windows_path() {
1535        let file_path = "C:\\Users\\zed\\project\\main.rs";
1536        let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
1537        match &parsed {
1538            MentionUri::File { abs_path } => {
1539                assert_eq!(abs_path, Path::new("C:\\Users\\zed\\project\\main.rs"));
1540            }
1541            _ => panic!("Expected File variant"),
1542        }
1543    }
1544
1545    #[test]
1546    fn test_parse_absolute_windows_file_path_with_row() {
1547        let file_path = "C:\\Users\\zed\\project\\main.rs:42";
1548        let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
1549        match &parsed {
1550            MentionUri::Selection {
1551                abs_path: path,
1552                line_range,
1553                ..
1554            } => {
1555                assert_eq!(
1556                    path.as_ref().unwrap(),
1557                    Path::new("C:\\Users\\zed\\project\\main.rs")
1558                );
1559                assert_eq!(line_range.start(), &41);
1560                assert_eq!(line_range.end(), &41);
1561            }
1562            _ => panic!("Expected Selection variant"),
1563        }
1564    }
1565
1566    #[test]
1567    fn test_parse_absolute_windows_file_path_with_fragment_line() {
1568        let file_path = "C:\\Users\\zed\\project\\main.rs#L42";
1569        let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
1570        match &parsed {
1571            MentionUri::Selection {
1572                abs_path: path,
1573                line_range,
1574                ..
1575            } => {
1576                assert_eq!(
1577                    path.as_ref().unwrap(),
1578                    Path::new("C:\\Users\\zed\\project\\main.rs")
1579                );
1580                assert_eq!(line_range.start(), &41);
1581                assert_eq!(line_range.end(), &41);
1582            }
1583            _ => panic!("Expected Selection variant"),
1584        }
1585    }
1586
1587    #[test]
1588    fn test_parse_backticked_absolute_file_path() {
1589        let file_path = "`/path/to/file.rs`";
1590        let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap();
1591        match &parsed {
1592            MentionUri::File { abs_path } => {
1593                assert_eq!(abs_path, Path::new("/path/to/file.rs"));
1594            }
1595            _ => panic!("Expected File variant"),
1596        }
1597    }
1598
1599    #[test]
1600    fn test_parse_backticked_absolute_file_path_with_fragment_line() {
1601        let file_path = "`/path/to/file.rs#L42`";
1602        let parsed = MentionUri::parse(file_path, PathStyle::Unix).unwrap();
1603        match &parsed {
1604            MentionUri::Selection {
1605                abs_path: path,
1606                line_range,
1607                ..
1608            } => {
1609                assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
1610                assert_eq!(line_range.start(), &41);
1611                assert_eq!(line_range.end(), &41);
1612            }
1613            _ => panic!("Expected Selection variant"),
1614        }
1615    }
1616
1617    #[test]
1618    fn test_parse_backticked_absolute_windows_file_path_with_fragment_line() {
1619        let file_path = "`C:\\Users\\zed\\project\\main.rs#L42`";
1620        let parsed = MentionUri::parse(file_path, PathStyle::Windows).unwrap();
1621        match &parsed {
1622            MentionUri::Selection {
1623                abs_path: path,
1624                line_range,
1625                ..
1626            } => {
1627                assert_eq!(
1628                    path.as_ref().unwrap(),
1629                    Path::new("C:\\Users\\zed\\project\\main.rs")
1630                );
1631                assert_eq!(line_range.start(), &41);
1632                assert_eq!(line_range.end(), &41);
1633            }
1634            _ => panic!("Expected Selection variant"),
1635        }
1636    }
1637
1638    #[test]
1639    fn test_single_line_number() {
1640        // https://github.com/zed-industries/zed/issues/46114
1641        let uri = uri!("file:///path/to/file.rs#L1872");
1642        let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
1643        match &parsed {
1644            MentionUri::Selection {
1645                abs_path: path,
1646                line_range,
1647                ..
1648            } => {
1649                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
1650                assert_eq!(line_range.start(), &1871);
1651                assert_eq!(line_range.end(), &1871);
1652            }
1653            _ => panic!("Expected Selection variant"),
1654        }
1655    }
1656
1657    #[test]
1658    fn test_dash_separated_line_range() {
1659        let uri = uri!("file:///path/to/file.rs#L10-20");
1660        let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
1661        match &parsed {
1662            MentionUri::Selection {
1663                abs_path: path,
1664                line_range,
1665                ..
1666            } => {
1667                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
1668                assert_eq!(line_range.start(), &9);
1669                assert_eq!(line_range.end(), &19);
1670            }
1671            _ => panic!("Expected Selection variant"),
1672        }
1673
1674        // Also test L10-L20 format
1675        let uri = uri!("file:///path/to/file.rs#L10-L20");
1676        let parsed = MentionUri::parse(uri, PathStyle::local()).unwrap();
1677        match &parsed {
1678            MentionUri::Selection {
1679                abs_path: path,
1680                line_range,
1681                ..
1682            } => {
1683                assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
1684                assert_eq!(line_range.start(), &9);
1685                assert_eq!(line_range.end(), &19);
1686            }
1687            _ => panic!("Expected Selection variant"),
1688        }
1689    }
1690
1691    #[test]
1692    fn test_parse_terminal_selection_uri() {
1693        let terminal_uri = "zed:///agent/terminal-selection?lines=42";
1694        let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
1695        match &parsed {
1696            MentionUri::TerminalSelection { line_count } => {
1697                assert_eq!(*line_count, 42);
1698            }
1699            _ => panic!("Expected TerminalSelection variant"),
1700        }
1701        assert_eq!(parsed.to_uri().to_string(), terminal_uri);
1702        assert_eq!(parsed.name(), "Terminal (42 lines)");
1703
1704        // Test single line
1705        let single_line_uri = "zed:///agent/terminal-selection?lines=1";
1706        let parsed_single = MentionUri::parse(single_line_uri, PathStyle::local()).unwrap();
1707        assert_eq!(parsed_single.name(), "Terminal (1 line)");
1708    }
1709
1710    #[test]
1711    fn test_disambiguated_name() {
1712        // Two files with the same name — should disambiguate with parent dir
1713        let file_a = MentionUri::File {
1714            abs_path: PathBuf::from(path!("/project/src/README.md")),
1715        };
1716        let file_b = MentionUri::File {
1717            abs_path: PathBuf::from(path!("/project/docs/README.md")),
1718        };
1719        assert_eq!(file_a.name(), "README.md");
1720        assert_eq!(file_b.name(), "README.md");
1721        assert_eq!(file_a.disambiguated_name(0), "README.md");
1722        assert_eq!(file_a.disambiguated_name(1), "src/README.md");
1723        assert_eq!(file_b.disambiguated_name(1), "docs/README.md");
1724
1725        // Files that still collide at one parent should grow further.
1726        let deep_a = MentionUri::File {
1727            abs_path: PathBuf::from(path!("/a/src/foo.rs")),
1728        };
1729        let deep_b = MentionUri::File {
1730            abs_path: PathBuf::from(path!("/b/src/foo.rs")),
1731        };
1732        assert_eq!(deep_a.disambiguated_name(1), "src/foo.rs");
1733        assert_eq!(deep_b.disambiguated_name(1), "src/foo.rs");
1734        assert_eq!(deep_a.disambiguated_name(2), "a/src/foo.rs");
1735        assert_eq!(deep_b.disambiguated_name(2), "b/src/foo.rs");
1736
1737        // Two skills with the same name — should disambiguate with source
1738        let global_skill = MentionUri::Skill {
1739            name: "create-skill".into(),
1740            source: "".into(),
1741            skill_file_path: PathBuf::from("/global/create-skill/SKILL.md"),
1742        };
1743        let project_skill = MentionUri::Skill {
1744            name: "create-skill".into(),
1745            source: "my-project".into(),
1746            skill_file_path: PathBuf::from("/project/create-skill/SKILL.md"),
1747        };
1748        assert_eq!(global_skill.name(), "create-skill");
1749        assert_eq!(global_skill.disambiguated_name(0), "create-skill");
1750        assert_eq!(global_skill.disambiguated_name(1), "create-skill (global)");
1751        assert_eq!(
1752            project_skill.disambiguated_name(1),
1753            "create-skill (my-project)"
1754        );
1755
1756        // A type without special disambiguation (Thread) — detail has no effect
1757        // (the value is a fixed point so the disambiguation loop terminates).
1758        let thread = MentionUri::Thread {
1759            id: acp::SessionId::new("123"),
1760            name: "My Thread".into(),
1761        };
1762        assert_eq!(thread.disambiguated_name(0), "My Thread");
1763        assert_eq!(thread.disambiguated_name(1), "My Thread");
1764        assert_eq!(thread.disambiguated_name(5), "My Thread");
1765
1766        // Edge case: file at filesystem root has no parent to show
1767        let root_file = MentionUri::File {
1768            abs_path: PathBuf::from(path!("/README.md")),
1769        };
1770        assert_eq!(root_file.disambiguated_name(1), "README.md");
1771        assert_eq!(root_file.disambiguated_name(5), "README.md");
1772    }
1773}
1774
Served at tenant.openagents/omega Member data and write actions are omitted.