diff --git a/src/commands/events.rs b/src/commands/events.rs index b5d947a..bbe5537 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -5,7 +5,7 @@ use polymarket_client_sdk::gamma::{ types::request::{EventByIdRequest, EventBySlugRequest, EventTagsRequest, EventsRequest}, }; -use super::is_numeric_id; +use super::{ResolvedId, resolve_id}; use crate::output::events::{print_event_detail, print_events_table}; use crate::output::tags::print_tags_table; use crate::output::{OutputFormat, print_json}; @@ -51,7 +51,7 @@ pub enum EventsCommand { /// Get a single event by ID or slug Get { - /// Event ID (numeric) or slug + /// Event ID (numeric), slug, or Polymarket URL id: String, }, @@ -93,13 +93,15 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor } EventsCommand::Get { id } => { - let is_numeric = is_numeric_id(&id); - let event = if is_numeric { - let req = EventByIdRequest::builder().id(id).build(); - client.event_by_id(&req).await? - } else { - let req = EventBySlugRequest::builder().slug(id).build(); - client.event_by_slug(&req).await? + let event = match resolve_id(&id, false) { + ResolvedId::Numeric(n) => { + let req = EventByIdRequest::builder().id(n).build(); + client.event_by_id(&req).await? + } + ResolvedId::Slug(slug) => { + let req = EventBySlugRequest::builder().slug(slug).build(); + client.event_by_slug(&req).await? + } }; match output { diff --git a/src/commands/markets.rs b/src/commands/markets.rs index 68e5491..4d872dd 100644 --- a/src/commands/markets.rs +++ b/src/commands/markets.rs @@ -11,7 +11,7 @@ use polymarket_client_sdk::gamma::{ }, }; -use super::is_numeric_id; +use super::{ResolvedId, resolve_id}; use crate::output::markets::{print_market_detail, print_markets_table}; use crate::output::tags::print_tags_table; use crate::output::{OutputFormat, print_json}; @@ -53,7 +53,7 @@ pub enum MarketsCommand { /// Get a single market by ID or slug Get { - /// Market ID (numeric) or slug + /// Market ID (numeric), slug, or Polymarket URL id: String, }, @@ -107,13 +107,15 @@ pub async fn execute( } MarketsCommand::Get { id } => { - let is_numeric = is_numeric_id(&id); - let market = if is_numeric { - let req = MarketByIdRequest::builder().id(id).build(); - client.market_by_id(&req).await? - } else { - let req = MarketBySlugRequest::builder().slug(id).build(); - client.market_by_slug(&req).await? + let market = match resolve_id(&id, true) { + ResolvedId::Numeric(n) => { + let req = MarketByIdRequest::builder().id(n).build(); + client.market_by_id(&req).await? + } + ResolvedId::Slug(slug) => { + let req = MarketBySlugRequest::builder().slug(slug).build(); + client.market_by_slug(&req).await? + } }; match output { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 671c0ee..811e3aa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -30,10 +30,106 @@ pub fn parse_condition_id(s: &str) -> anyhow::Result { .map_err(|_| anyhow::anyhow!("Invalid condition ID: must be a 0x-prefixed 32-byte hex")) } +/// Parsed Polymarket URL with event slug and optional market slug. +#[derive(Debug, PartialEq)] +pub struct PolymarketUrl { + pub event_slug: String, + pub market_slug: Option, +} + +/// Parse a Polymarket URL into its event and optional market slugs. +/// +/// Accepts URLs with or without scheme (`https://`, `http://`), with or without +/// `www.`, and strips query strings, fragments, and trailing slashes. +/// +/// Returns `None` for non-Polymarket URLs or URLs missing `/event/`. +pub fn parse_polymarket_url(input: &str) -> Option { + // Strip scheme if present + let without_scheme = input + .strip_prefix("https://") + .or_else(|| input.strip_prefix("http://")) + .unwrap_or(input); + + // Split host from path at the first '/' + let (host, path) = match without_scheme.find('/') { + Some(i) => (&without_scheme[..i], &without_scheme[i..]), + None => return None, // No path at all + }; + + // Verify it's a polymarket.com host + let host_lower = host.to_ascii_lowercase(); + if host_lower != "polymarket.com" && host_lower != "www.polymarket.com" { + return None; + } + + // Strip query string and fragment + let path = path.split('?').next().unwrap_or(path); + let path = path.split('#').next().unwrap_or(path); + + // Strip trailing slash + let path = path.strip_suffix('/').unwrap_or(path); + + // Expect /event/[/] + let path = path.strip_prefix("/event/")?; + if path.is_empty() { + return None; + } + + let mut segments = path.split('/'); + let event_slug = segments.next()?.to_string(); + if event_slug.is_empty() { + return None; + } + + let market_slug = segments + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + Some(PolymarketUrl { + event_slug, + market_slug, + }) +} + +/// What `resolve_id` determined the input to be. +#[derive(Debug, PartialEq)] +pub enum ResolvedId { + /// A numeric API id (e.g. "12345"). + Numeric(String), + /// A slug extracted from a Polymarket URL or passed directly. + Slug(String), +} + +/// Resolve a user-provided identifier that may be a Polymarket URL, a numeric +/// ID, or a plain slug. +/// +/// Accepts URLs like `https://polymarket.com/event/[/]`. +/// When `prefer_market` is true and the URL contains a market slug, the market +/// slug is used; otherwise the event slug is used. +pub fn resolve_id(input: &str, prefer_market: bool) -> ResolvedId { + if let Some(parsed) = parse_polymarket_url(input) { + let slug = if prefer_market { + parsed.market_slug.unwrap_or(parsed.event_slug) + } else { + parsed.event_slug + }; + return ResolvedId::Slug(slug); + } + + if is_numeric_id(input) { + ResolvedId::Numeric(input.to_string()) + } else { + ResolvedId::Slug(input.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; + // ── is_numeric_id ────────────────────────────────────────────── + #[test] fn is_numeric_id_pure_digits() { assert!(is_numeric_id("12345")); @@ -52,6 +148,8 @@ mod tests { assert!(!is_numeric_id("")); } + // ── parse_address / parse_condition_id ────────────────────────── + #[test] fn parse_address_valid_hex() { let addr = "0x0000000000000000000000000000000000000001"; @@ -87,4 +185,180 @@ mod tests { let err = parse_condition_id("garbage").unwrap_err().to_string(); assert!(err.contains("32-byte"), "got: {err}"); } + + // ── parse_polymarket_url ─────────────────────────────────────── + + #[test] + fn parse_url_standard_event() { + let url = "https://polymarket.com/event/will-bitcoin-hit-100k"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "will-bitcoin-hit-100k"); + assert_eq!(parsed.market_slug, None); + } + + #[test] + fn parse_url_event_with_market() { + let url = "https://polymarket.com/event/will-bitcoin-hit-100k/bitcoin-100k-by-march"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "will-bitcoin-hit-100k"); + assert_eq!(parsed.market_slug.as_deref(), Some("bitcoin-100k-by-march")); + } + + #[test] + fn parse_url_http_scheme() { + let url = "http://polymarket.com/event/some-event"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + } + + #[test] + fn parse_url_no_scheme() { + let url = "polymarket.com/event/some-event/some-market"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + assert_eq!(parsed.market_slug.as_deref(), Some("some-market")); + } + + #[test] + fn parse_url_www_prefix() { + let url = "https://www.polymarket.com/event/some-event"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + } + + #[test] + fn parse_url_www_no_scheme() { + let url = "www.polymarket.com/event/some-event"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + } + + #[test] + fn parse_url_trailing_slash() { + let url = "https://polymarket.com/event/some-event/"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + assert_eq!(parsed.market_slug, None); + } + + #[test] + fn parse_url_trailing_slash_with_market() { + let url = "https://polymarket.com/event/some-event/some-market/"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + assert_eq!(parsed.market_slug.as_deref(), Some("some-market")); + } + + #[test] + fn parse_url_with_query_string() { + let url = "https://polymarket.com/event/some-event/some-market?tid=abc123"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + assert_eq!(parsed.market_slug.as_deref(), Some("some-market")); + } + + #[test] + fn parse_url_with_fragment() { + let url = "https://polymarket.com/event/some-event#comments"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + } + + #[test] + fn parse_url_with_query_and_fragment() { + let url = "https://polymarket.com/event/some-event/some-market?tid=1#top"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "some-event"); + assert_eq!(parsed.market_slug.as_deref(), Some("some-market")); + } + + #[test] + fn parse_url_extra_path_segments_ignored() { + let url = "https://polymarket.com/event/my-event/my-market/extra/stuff"; + let parsed = parse_polymarket_url(url).unwrap(); + assert_eq!(parsed.event_slug, "my-event"); + assert_eq!(parsed.market_slug.as_deref(), Some("my-market")); + } + + #[test] + fn parse_url_rejects_non_polymarket_domain() { + assert!(parse_polymarket_url("https://example.com/event/foo").is_none()); + assert!(parse_polymarket_url("https://notpolymarket.com/event/foo").is_none()); + } + + #[test] + fn parse_url_rejects_missing_event_prefix() { + assert!(parse_polymarket_url("https://polymarket.com/markets/foo").is_none()); + assert!(parse_polymarket_url("https://polymarket.com/foo").is_none()); + } + + #[test] + fn parse_url_rejects_empty_slug() { + assert!(parse_polymarket_url("https://polymarket.com/event/").is_none()); + } + + #[test] + fn parse_url_rejects_plain_slug() { + assert!(parse_polymarket_url("will-bitcoin-hit-100k").is_none()); + } + + #[test] + fn parse_url_rejects_numeric_id() { + assert!(parse_polymarket_url("12345").is_none()); + } + + #[test] + fn parse_url_rejects_no_path() { + assert!(parse_polymarket_url("https://polymarket.com").is_none()); + assert!(parse_polymarket_url("polymarket.com").is_none()); + } + + // ── resolve_id ───────────────────────────────────────────────── + + #[test] + fn resolve_id_numeric() { + assert_eq!( + resolve_id("12345", false), + ResolvedId::Numeric("12345".to_string()) + ); + assert_eq!( + resolve_id("12345", true), + ResolvedId::Numeric("12345".to_string()) + ); + } + + #[test] + fn resolve_id_plain_slug() { + assert_eq!( + resolve_id("will-bitcoin-hit-100k", false), + ResolvedId::Slug("will-bitcoin-hit-100k".to_string()) + ); + } + + #[test] + fn resolve_id_url_prefer_market_true() { + let url = "https://polymarket.com/event/my-event/my-market"; + assert_eq!( + resolve_id(url, true), + ResolvedId::Slug("my-market".to_string()) + ); + } + + #[test] + fn resolve_id_url_prefer_market_false() { + let url = "https://polymarket.com/event/my-event/my-market"; + assert_eq!( + resolve_id(url, false), + ResolvedId::Slug("my-event".to_string()) + ); + } + + #[test] + fn resolve_id_url_no_market_prefer_market_true() { + let url = "https://polymarket.com/event/my-event"; + assert_eq!( + resolve_id(url, true), + ResolvedId::Slug("my-event".to_string()) + ); + } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 41d3d11..48433ff 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -486,3 +486,34 @@ fn wallet_address_succeeds_or_fails_gracefully() { // Either succeeds or fails with an error message — not a panic assert!(output.status.success() || !output.stderr.is_empty()); } + +#[test] +fn markets_get_accepts_url() { + // Verify the CLI accepts a Polymarket URL without argument-level rejection. + // The command will fail at the API level (nonexistent slug), but that's fine — + // the point is it doesn't fail at argument parsing. + let output = polymarket() + .args([ + "markets", + "get", + "https://polymarket.com/event/test-event/test-market", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("error: invalid value"), "stderr: {stderr}"); +} + +#[test] +fn events_get_accepts_url() { + let output = polymarket() + .args([ + "events", + "get", + "https://polymarket.com/event/test-event/test-market", + ]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("error: invalid value"), "stderr: {stderr}"); +}