diff --git a/types/src/lib.rs b/types/src/lib.rs index 516cf935f15fca97081b39b988da5be894c67725..ceaa574cd32b7e7eb397c9a3191fcbc037b8c606 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -pub mod room_route; pub mod url_normalize; pub mod paths; pub mod timeago; @@ -8,9 +7,9 @@ pub mod timeago; pub use paths::{ canonicalize_item, canonicalize_tag, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url, CanonicalItemUrl, ForumThreadUrl, GardenItemUrl, RelativePath, SLUG_TILDE_ONTOLOGY_ROOT, + room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN, TildeHttpPathTail, TildeOntologyPath, TildePath, tilde_http_path_to_canonical, }; -pub use room_route::{room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN}; pub use url_normalize::normalize_http_identity_url; /// Max characters returned for a garden item body unless `full=true` / `--full` (API + CLI). @@ -661,6 +660,3 @@ pub struct VoteResponse { pub ranking: Vec, pub next: NextMoves, } - -#[cfg(test)] -mod url_identity_tests; diff --git a/types/src/paths.rs b/types/src/paths.rs index 98787a5fb481a1599556564bbbbd1d54dc693fc3..5c60e762c2ca74d79c74237097d5bcc02ba74af2 100644 --- a/types/src/paths.rs +++ b/types/src/paths.rs @@ -8,6 +8,7 @@ //! - **[`TildeHttpPathTail`]** — capture from `GET /~/*path` or `…/r/{short}{slug}/~/…` (the `*path` segment). //! - **`-/…` wire form** — external items; see [`canonicalize_item`] dash branch. //! - **[`GardenItemUrl`], [`ForumThreadUrl`]** — JSON / browser href surfaces. +//! - **[`ROOM_SHORT_ID_LEN`] / [`room_route_segment`]** — `/r/{short}{slug}` vs wire `short/slug`. use std::borrow::Borrow; use std::fmt; @@ -15,7 +16,6 @@ use std::ops::Deref; use serde::{Deserialize, Serialize}; -use crate::room_route::room_route_segment; use crate::url_normalize::{host_preserves_dash_path_case, normalize_http_identity_url}; // --------------------------------------------------------------------------- @@ -35,6 +35,46 @@ pub fn normalize_slug_ontology_storage_url(s: &str) -> String { } } +// --------------------------------------------------------------------------- +// Private room HTTP path (`/r/{short}{slug}`; wire id remains `short/slug`) +// --------------------------------------------------------------------------- + +/// Byte length of the random `short` segment in `short/slug` room ids (matches server `gen_short_id`). +pub const ROOM_SHORT_ID_LEN: usize = 7; + +/// `ab12cde/my-room` → `ab12cdemy-room` for a single `/r/…` path segment. +pub fn room_route_segment(room_id: &str) -> Option { + let (short, slug) = room_id.split_once('/')?; + if short.len() != ROOM_SHORT_ID_LEN || short.is_empty() || slug.is_empty() { + return None; + } + if !short + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z')) + { + return None; + } + Some(format!("{short}{slug}")) +} + +/// `/r/{short}{slug}` path segment → `short/slug` wire id (inverse of [`room_route_segment`]). +pub fn room_id_from_route_segment(seg: &str) -> Option { + if seg.len() <= ROOM_SHORT_ID_LEN { + return None; + } + let (short, slug) = seg.split_at(ROOM_SHORT_ID_LEN); + if short.is_empty() || slug.is_empty() { + return None; + } + if !short + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z')) + { + return None; + } + Some(format!("{short}/{slug}")) +} + // --------------------------------------------------------------------------- // Normalization (moved from server `canonical_path`) // --------------------------------------------------------------------------- @@ -823,4 +863,17 @@ mod tests { Some("https://github.com/org/repo/issues") ); } + + #[test] + fn round_trip_room_segment() { + let id = "9ab12cd/my-room"; + let seg = room_route_segment(id).unwrap(); + assert_eq!(seg, "9ab12cdmy-room"); + assert_eq!(room_id_from_route_segment(&seg).as_deref(), Some(id)); + } + + #[test] + fn too_short_room_route_segment_rejected() { + assert!(room_id_from_route_segment("9ab12cd").is_none()); + } } diff --git a/types/src/room_route.rs b/types/src/room_route.rs deleted file mode 100644 index 4f4780c88e30f2b28e2cfcd7aee713d39dbd1c66..0000000000000000000000000000000000000000 --- a/types/src/room_route.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! HTTP path encoding for private rooms: `/r/{short}{slug}` (short is fixed width). - -/// Byte length of the random `short` segment in `short/slug` room ids. -/// Must match room creation (`gen_short_id`) and [`super::paths`][] URL builders. -pub const ROOM_SHORT_ID_LEN: usize = 7; - -/// `ab12cde/my-room` → `ab12cdemy-room` for a single `/r/…` path segment. -pub fn room_route_segment(room_id: &str) -> Option { - let (short, slug) = room_id.split_once('/')?; - if short.len() != ROOM_SHORT_ID_LEN || short.is_empty() || slug.is_empty() { - return None; - } - if !short - .bytes() - .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z')) - { - return None; - } - Some(format!("{short}{slug}")) -} - -/// `/r/{short}{slug}` path segment → `short/slug` wire id (inverse of [`room_route_segment`]). -pub fn room_id_from_route_segment(seg: &str) -> Option { - if seg.len() <= ROOM_SHORT_ID_LEN { - return None; - } - let (short, slug) = seg.split_at(ROOM_SHORT_ID_LEN); - if short.is_empty() || slug.is_empty() { - return None; - } - if !short - .bytes() - .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z')) - { - return None; - } - Some(format!("{short}/{slug}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trip_room_segment() { - let id = "9ab12cd/my-room"; - let seg = room_route_segment(id).unwrap(); - assert_eq!(seg, "9ab12cdmy-room"); - assert_eq!(room_id_from_route_segment(&seg).as_deref(), Some(id)); - } - - #[test] - fn too_short_segment_rejected() { - assert!(room_id_from_route_segment("9ab12cd").is_none()); - } -} diff --git a/types/src/url_identity_tests.rs b/types/src/url_identity_tests.rs deleted file mode 100644 index e2ff0a9a64a88049e31f20979c703777df6a9b65..0000000000000000000000000000000000000000 --- a/types/src/url_identity_tests.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! How `url::Url` behaves as `HashMap` keys (`Eq` + `Hash`). -//! -//! If `ItemId::External` stores `Url`, these tests are the contract you are buying into -//! (or the baseline before you add a custom normalization layer). - -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; -use url::Url; - -fn hash_one(url: &Url) -> u64 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - url.hash(&mut h); - h.finish() -} - -#[test] -fn identical_parse_strings_are_eq_and_share_hash_bucket() { - let a = Url::parse("https://example.com/path").unwrap(); - let b = Url::parse("https://example.com/path").unwrap(); - assert_eq!(a, b); - assert_eq!(hash_one(&a), hash_one(&b)); - - let mut m: HashMap = HashMap::new(); - m.insert(a, 1); - *m.entry(b).or_default() += 10; - assert_eq!(m.len(), 1); - assert_eq!(m[&Url::parse("https://example.com/path").unwrap()], 11); -} - -#[test] -fn host_is_ascii_lowercase_in_eq() { - let lower = Url::parse("https://examplE.com/").unwrap(); - let upper = Url::parse("https://EXAMPLE.com/").unwrap(); - assert_eq!(lower, upper); - assert_eq!(hash_one(&lower), hash_one(&upper)); -} - -#[test] -fn path_space_normalizes_to_percent_encoding_so_forms_merge() { - let encoded = Url::parse("https://example.com/a%20b").unwrap(); - let decoded = Url::parse("https://example.com/a b").unwrap(); - // Parser normalizes both to the same internal path (`/a%20b`). - assert_eq!(encoded, decoded); - assert_eq!(hash_one(&encoded), hash_one(&decoded)); - - let mut m: HashMap = HashMap::new(); - m.insert(encoded, "first"); - assert_eq!(m.insert(decoded, "second"), Some("first")); - assert_eq!(m.len(), 1); - assert_eq!(m.values().next().copied(), Some("second")); -} - -#[test] -fn encoded_slash_in_segment_stays_distinct_from_real_path_separator() { - let encoded = Url::parse("https://example.com/a%2Fb").unwrap(); - let real_slash = Url::parse("https://example.com/a/b").unwrap(); - assert_ne!(encoded, real_slash); - assert_ne!(hash_one(&encoded), hash_one(&real_slash)); -} - -#[test] -fn trailing_slash_on_path_is_significant_for_eq() { - let with_slash = Url::parse("https://example.com/foo/").unwrap(); - let no_slash = Url::parse("https://example.com/foo").unwrap(); - assert_ne!(with_slash, no_slash); - assert_ne!(hash_one(&with_slash), hash_one(&no_slash)); -} - -#[test] -fn default_http_port_80_is_normalized_in_representation() { - let explicit = Url::parse("http://example.com:80/").unwrap(); - let implicit = Url::parse("http://example.com/").unwrap(); - assert_eq!(explicit, implicit); - assert_eq!(hash_one(&explicit), hash_one(&implicit)); -} - -#[test] -fn default_https_port_443_is_normalized() { - let explicit = Url::parse("https://example.com:443/foo").unwrap(); - let implicit = Url::parse("https://example.com/foo").unwrap(); - assert_eq!(explicit, implicit); -} - -#[test] -fn non_default_port_is_part_of_identity() { - let a = Url::parse("https://example.com:444/").unwrap(); - let b = Url::parse("https://example.com:445/").unwrap(); - assert_ne!(a, b); -} - -#[test] -fn empty_path_vs_slash_only_path_may_differ() { - let root = Url::parse("https://example.com").unwrap(); - let slash = Url::parse("https://example.com/").unwrap(); - // Both serialize to `https://example.com/` in practice for this crate — verify. - assert_eq!(root, slash, "document: root and trailing-slash-only merge for this parser"); -} - -#[test] -fn scheme_case_is_normalized_to_lowercase() { - let lower = Url::parse("https://example.com/").unwrap(); - let upper = Url::parse("HTTPS://example.com/").unwrap(); - assert_eq!(lower, upper); -} - -#[test] -fn fragment_is_part_of_eq_and_hash() { - let no_frag = Url::parse("https://example.com/a").unwrap(); - let frag = Url::parse("https://example.com/a#section").unwrap(); - assert_ne!( - no_frag, frag, - "#fragment is included in PartialEq — anchors are different HashMap keys" - ); - assert_ne!(hash_one(&no_frag), hash_one(&frag)); -} - -#[test] -fn query_order_and_encoding_can_split_identity() { - let a = Url::parse("https://example.com/?b=2&a=1").unwrap(); - let b = Url::parse("https://example.com/?a=1&b=2").unwrap(); - assert_ne!(a, b, "query pairs order is preserved in serialization"); - - let plus = Url::parse("https://example.com/?q=a+b").unwrap(); - let encoded = Url::parse("https://example.com/?q=a%20b").unwrap(); - assert_ne!(plus, encoded, "space as + vs %20 — different keys unless normalized"); -} diff --git a/types/src/url_normalize.rs b/types/src/url_normalize.rs index d5bc913f33ad29cd0cd1c7158e28e4fefdbf5f5d..999baa4f3367b632763b44ddc77b894451207c50 100644 --- a/types/src/url_normalize.rs +++ b/types/src/url_normalize.rs @@ -232,3 +232,127 @@ mod tests { ); } } + +/// `url::Url` as a `HashMap` key: `Eq` / `Hash` behavior (baseline if we store external ids as `Url`). +#[cfg(test)] +mod url_identity_tests { + use std::collections::HashMap; + use std::hash::{Hash, Hasher}; + use url::Url; + + fn hash_one(url: &Url) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut h); + h.finish() + } + + #[test] + fn identical_parse_strings_are_eq_and_share_hash_bucket() { + let a = Url::parse("https://example.com/path").unwrap(); + let b = Url::parse("https://example.com/path").unwrap(); + assert_eq!(a, b); + assert_eq!(hash_one(&a), hash_one(&b)); + + let mut m: HashMap = HashMap::new(); + m.insert(a, 1); + *m.entry(b).or_default() += 10; + assert_eq!(m.len(), 1); + assert_eq!(m[&Url::parse("https://example.com/path").unwrap()], 11); + } + + #[test] + fn host_is_ascii_lowercase_in_eq() { + let lower = Url::parse("https://examplE.com/").unwrap(); + let upper = Url::parse("https://EXAMPLE.com/").unwrap(); + assert_eq!(lower, upper); + assert_eq!(hash_one(&lower), hash_one(&upper)); + } + + #[test] + fn path_space_normalizes_to_percent_encoding_so_forms_merge() { + let encoded = Url::parse("https://example.com/a%20b").unwrap(); + let decoded = Url::parse("https://example.com/a b").unwrap(); + assert_eq!(encoded, decoded); + assert_eq!(hash_one(&encoded), hash_one(&decoded)); + + let mut m: HashMap = HashMap::new(); + m.insert(encoded, "first"); + assert_eq!(m.insert(decoded, "second"), Some("first")); + assert_eq!(m.len(), 1); + assert_eq!(m.values().next().copied(), Some("second")); + } + + #[test] + fn encoded_slash_in_segment_stays_distinct_from_real_path_separator() { + let encoded = Url::parse("https://example.com/a%2Fb").unwrap(); + let real_slash = Url::parse("https://example.com/a/b").unwrap(); + assert_ne!(encoded, real_slash); + assert_ne!(hash_one(&encoded), hash_one(&real_slash)); + } + + #[test] + fn trailing_slash_on_path_is_significant_for_eq() { + let with_slash = Url::parse("https://example.com/foo/").unwrap(); + let no_slash = Url::parse("https://example.com/foo").unwrap(); + assert_ne!(with_slash, no_slash); + assert_ne!(hash_one(&with_slash), hash_one(&no_slash)); + } + + #[test] + fn default_http_port_80_is_normalized_in_representation() { + let explicit = Url::parse("http://example.com:80/").unwrap(); + let implicit = Url::parse("http://example.com/").unwrap(); + assert_eq!(explicit, implicit); + assert_eq!(hash_one(&explicit), hash_one(&implicit)); + } + + #[test] + fn default_https_port_443_is_normalized() { + let explicit = Url::parse("https://example.com:443/foo").unwrap(); + let implicit = Url::parse("https://example.com/foo").unwrap(); + assert_eq!(explicit, implicit); + } + + #[test] + fn non_default_port_is_part_of_identity() { + let a = Url::parse("https://example.com:444/").unwrap(); + let b = Url::parse("https://example.com:445/").unwrap(); + assert_ne!(a, b); + } + + #[test] + fn empty_path_vs_slash_only_path_may_differ() { + let root = Url::parse("https://example.com").unwrap(); + let slash = Url::parse("https://example.com/").unwrap(); + assert_eq!(root, slash, "root and trailing-slash-only merge for this parser"); + } + + #[test] + fn scheme_case_is_normalized_to_lowercase() { + let lower = Url::parse("https://example.com/").unwrap(); + let upper = Url::parse("HTTPS://example.com/").unwrap(); + assert_eq!(lower, upper); + } + + #[test] + fn fragment_is_part_of_eq_and_hash() { + let no_frag = Url::parse("https://example.com/a").unwrap(); + let frag = Url::parse("https://example.com/a#section").unwrap(); + assert_ne!( + no_frag, frag, + "#fragment is included in PartialEq — anchors are different HashMap keys" + ); + assert_ne!(hash_one(&no_frag), hash_one(&frag)); + } + + #[test] + fn query_order_and_encoding_can_split_identity() { + let a = Url::parse("https://example.com/?b=2&a=1").unwrap(); + let b = Url::parse("https://example.com/?a=1&b=2").unwrap(); + assert_ne!(a, b, "query pairs order is preserved in serialization"); + + let plus = Url::parse("https://example.com/?q=a+b").unwrap(); + let encoded = Url::parse("https://example.com/?q=a%20b").unwrap(); + assert_ne!(plus, encoded, "space as + vs %20 — different keys unless normalized"); + } +}