Skip to repository content1774 lines · 66.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:09:56.626Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
mention.rs
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