Side A merely deletes a speculative design doc, removing no functional code and providing no lasting value beyond repo tidiness. Side B consolidates real code by merging room_route.rs into paths.rs and moving url_identity_tests.rs into url_normalize.rs, reducing module fragmentation while preserving all functionality and tests intact, which is a genuine (if modest) structural improvement.
constitution · epochs · watch · epoch 3
c_60c575d6de0d (tommy-mor) vs c_554c5efe648b (tommy-mor)
download prompt · raw event · cmp_04c21ef8da4079
council reasoning
Side B performs a focused module consolidation (inlining room_route helpers + tests into paths.rs and url_identity_tests into url_normalize.rs) that slightly improves cohesion without changing behavior. Side A only deletes an unused design-doc PLAN.md, which adds no lasting code, tests, or runtime value.
Side B performs a real code consolidation by moving the room route helpers (`ROOM_SHORT_ID_LEN`, `room_route_segment`, `room_id_from_route_segment`) into `paths.rs`, updating re-exports, and preserving behavior with migrated tests. Side A only deletes `PLAN.md`, removing design documentation without improving functionality or fixing a bug.
sides
A — c_60c575d6de0d (tommy-mor)
message
[e3da4045] Delete PLAN.md
diff preview
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index aa1158978da3ee6da362f2704b30c937eb3367cd..0000000000000000000000000000000000000000
--- a/PLAN.md
+++ /dev/null
@@ -1,385 +0,0 @@
-# Extensible URLs — Design Plan
-
-*Addresses [#134](https://github.com/sortersocial/slug/issues/134)*
-
----
-
-## Problem Statement
-
-External URLs (`-/host/path`) already exist as first-class garden items: they have `ItemId::Web` identity, `parent()`/child edges, breadcrumbs, and rankings. But the system only knows about external items that a human manually declared via the DSL. There is no mechanism to:
-
-1. **Automatically discover children** of an external URL (e.g. list the current issues under `github.com/org/repo/issues`).
-2. **Navigate the URL hierarchy** the way you can navigate `~/` — clicking into `github.com/org` should show repos, clicking a repo should show its structure (issues, pulls, commits, etc.).
-3. **Display the external page itself** when visiting a leaf item (currently shows "This is an external scope." with a disabled agent button).
-4. **Keep external children in sync** as the upstream source changes (new issues opened, repos created, etc.).
-
-The goal: URL structure becomes a votable/explorable ontology identical in UX to `~/`, with per-domain resolvers that can populate children automatically.
-
----
-
-## Current Architecture (relevant subsystems)
-
-### Item identity pipeline
-
-```
-User input ("~/a/b", "-/github.com/org/repo", "https://example.com/foo")
- → canonicalize_item() [types/src/item_wire.rs]
- → normalize_http_identity_url() [types/src/url_normalize.rs]
- → ItemId::parse() [types/src/item_id.rs]
- → ItemId { Root | Local | Web | Opaque }
-```
-
-**Key properties of `ItemId::Web`:**
-- Storage form is a normalized `https://…` string.
-- `parent()` strips the last path segment (host-only items have no parent).
-- `display_path()` renders as `-/host/path`.
-- Parent→child edges are built by `add_child_edge` in the reducer when items are ingested.
-
-### URL normalization (`url_normalize.rs`)
-
-Currently handles:
-- Query-pair sorting (lexicographic by lowercase key, then value).
-- YouTube family: `youtu.be`, `/embed/`, `/v/`, `/shorts/`, `m.youtube.com` → canonical `www.youtube.com/watch?v=ID` or `www.youtube.com/shorts/ID`.
-- `host_preserves_dash_path_case` — YouTube hosts keep case (video IDs are case-sensitive); all other hosts lowercase path segments.
-
-### External resolver (`external_resolver.rs`)
-
-A trait stub:
-```rust
-pub trait ExternalResolver: Send + Sync {
- fn domain_match(&self) -> &'static str;
- fn normalize(&self, path: &str) -> String;
- async fn fetch_body(&self, item: &ItemId) -> Result<String, String>;
-}
-```
-
-Only `DefaultExternalResolver` exists (returns "external fetch not implemented").
-
-### Garden rendering (`html/garden.rs`, `html/breadcrumb_path.rs`)
-
-- `ExternalOntologyPath::from_input` parses `/-/*path` into segments for breadcrumbs.
-- `render_scope_view` shows the item body (if any), children rankings, and vote history.
-- When an external item has no body: shows "This is an external scope." + disabled "Kick off an Agent Run to import and rank items" button.
-- Breadcrumbs split by `/` from the `https://` storage form — each segment is clickable.
-
-### DSL parsing (`dsl.rs`)
-
-Items can be referenced as:
-- `~/path/segments` — slug ontology.
-- `-/host/path/segments` — external items.
-- `https://...` / `http://...` — full URLs.
-
-The `-/` lexer accepts: alphanumeric, `_-/.?=&%:#+@~` — broad enough for query strings and fragments.
-
-### Thread/ingest model
-
-Items live in the garden; threads provide the temporal context. An `Ingest` event contains raw DSL text in a `thread_tag`. The DSL is parsed during `apply_ingest_to_content`, which creates items, registers bodies, records votes, and builds `item_children` edges.
-
----
-
-## Design
-
-### 1. Ingesting external URLs into threads
-
-**Mechanism:** External URLs are already valid DSL item references. A user (or automated agent) can write:
-
-```
--/github.com/sortersocial/slug/issues
- The issues list for the slug repo.
-```
-
-This already works today — it creates an `ItemId::Web("https://github.com/sortersocial/slug/issues")` with a body, registers parent edges up through `github.com/sortersocial/slug` → `github.com/sortersocial` → `github.com`, and makes it browsable at `/-/github.com/sortersocial/slug/issues`.
-
-**What needs to change for auto-population:** When someone navigates to (or explicitly requests) an external scope, the system should be able to auto-populate its children. This is the job of domain-specific resolvers.
-
-### 2. Domain resolver system
-
-Extend the existing `ExternalResolver` trait into a **registry of domain resolvers**.
-
-```rust
-pub trait DomainResolver: Send + Sync {
- /// Host patterns this resolver handles (e.g. "github.com").
- fn matches_host(&self, host: &str) -> bool;
-
- /// Given a parent external URL, discover its direct children.
- /// Returns (child_url, title, optional_body) tuples.
- async fn list_children(&self, parent: &ItemId) -> Result<Vec<ResolvedChild>, ResolverError>;
-
- /// Fetch/compute a body for a single item (e.g. issue description, README excerpt).
- async fn fetch_body(&self, item: &ItemId) -> Result<String, ResolverError>;
-
- /// Domain-specific URL normalization beyond the generic pipeline.
- fn normalize(&self, url: &str) -> Option<String>;
-}
-
-pub struct ResolvedChild {
- pub url: String, // canonical URL
- pub title: String, // display title
- pub body: Option<String>,
-}
-```
-
-**Registry:** `AppState` holds a `Vec<Arc<dyn DomainResolver>>`. On boot, register configured resolvers (initially just GitHub). Resolver lookup: find first where `matches_host(item_host)` returns true; fall back to `DefaultResolver` (which can still do generic things like fetching `<title>` tags).
-
-**Synthetic ingests:** When a resolver returns children, the server creates synthetic `Ingest` events attributed to a system principal (e.g. `@system:resolver`). These go through the normal `write_actor` → JSONL → reducer pipeline so they are durable, replayable, and show up in thread feeds.
-
-**Thread assignment:** Resolver-created items should land in a thread. Candidates:
-- **Option A:** `#import/<host>` (e.g. `#import/github.com`) — groups all resolver activity by domain.
-- **Option B:** `#import/<parent_path>` (e.g. `#import/github.com/org/repo/issues`) — groups by the scope that was resolved.
-- **Recommendation: Option B.** It's more specific and gives users a thread to follow for a particular external scope. Thread tag format: `import:<display_path>` (e.g. `import:-/github.com/org/repo/issues`). The `:` separates the thread namespace from user-created `#` threads while reusing the same `canonicalize_tag` pipeline.
-
-### 3. URL canonicalization
-
-The existing pipeline (`canonicalize_item` → `normalize_http_identity_url` → `ItemId::parse`) is already solid. Extend it:
-
-#### 3a. Current canonicalization rules (keep)
-- Lowercase host.
-- Lowercase path segments (except case-sensitive hosts like YouTube).
-- Sort query pairs by lowercase key.
-- YouTube: `youtu.be/ID` → `youtube.com/watch?v=ID`, `/embed/ID` → `/watch?v=ID`, etc.
-- Strip default ports (80/443).
-- Trim trailing slashes (host-only).
-
-#### 3b. New canonicalization rules (add)
-
-**Fragment stripping:**
-- By default, strip `#fragment` from URLs used as item identity. Fragments identify within-page positions, not distinct resources. `github.com/org/repo/issues/42` and `github.com/org/repo/issues/42#issuecomment-123` should resolve to the same item.
-- Exception: some sites use fragments as primary routing (e.g. single-page apps). Resolver-specific `normalize` can preserve fragments where the domain requires it.
-- Implementation: add `strip_fragment(u: &mut Url)` call in `normalize_http_identity_url`, before `sort_query_pairs`.
-
-**Scheme normalization:**
-- Already handled: `http://` and `https://` both pass through `canonicalize_item`. However, `http://example.com` and `https://example.com` produce different `ItemId::Web` values.
-- Policy decision: **prefer `https://`**. In `normalize_http_identity_url`, if scheme is `http`, upgrade to `https` (with an opt-out list for known http-only sites if needed).
-- This is debatable. Alternative: leave scheme as-is, since some sites genuinely differ. Start with scheme-preserving and let resolver `normalize()` handle specific cases.
-
-**Trailing-path slash normalization:**
-- Currently `strip_redundant_root_slash` only handles host-only URLs. Extend to strip trailing `/` from all paths: `github.com/org/repo/` → `github.com/org/repo`.
-- Already partially handled in `canonicalize_item` which trims trailing `/` from each segment during construction.
-
-**`www.` stripping:**
-- Currently only done for YouTube. Consider generalizing: `www.example.com` → `example.com` for identity purposes.
-- Risk: some sites serve different content at `www.` vs bare domain. Start with YouTube only; add to resolver `normalize()` per domain.
-
-#### 3c. Additional URL equivalences to handle
-
-| Input form | Canonical form | Notes |
-|---|---|---|
-| `youtu.be/ID` | `https://www.youtube.com/watch?v=ID` | Already handled |
-| `youtube.com/embed/ID` | `https://www.youtube.com/watch?v=ID` | Already handled |
-| `youtube.com/shorts/ID` | `https://www.youtube.com/shorts/ID` | Already handled (kept as shorts) |
-| `m.youtube.com/watch?v=ID` | `https://www.youtube.com/watch?v=ID` | Already handled |
-| `github.com/ORG/REPO` | `https://github.com/org/repo` | Path lowercased (already handled by generic lowercasing) |
-| `github.com/ORG/REPO.git` | `https://github.com/org/repo` | Strip `.git` suffix — add to GitHub resolver `normalize()` |
-| `x.com/user/status/123` | `https://x.com/user/status/123` | Preserve as-is (or normalize `twitter.com` → `x.com`) |
-| `twitter.com/user/status/123` | `https://x.com/user/status/123` | Add Twitter→X rewrite in `url_normalize.rs` |
-| `reddit.com/r/sub/comments/id/…` | normalize to canonical Reddit URL | Reddit resolver |
-| URL with tracking params (`utm_*`, `fbclid`, etc.) | Strip known tracking params | Generic rule in `normalize_http_identity_url` |
-
-### 4. Query parameters
-
-Query parameters are tricky because they serve multiple purposes:
-
-**Identity-bearing:** `youtube.com/watch?v=ID` — the `v` param is the resource identity. Stripping it destroys the reference. Same for search queries, filter params on some sites.
-
-**Tracking/noise:** `?utm_source=…`, `?fbclid=…`, `?ref=…` — these should be stripped for canonical identity.
-
-**Pagination/state:** `?page=2`, `?sort=newest` — debatable. In the URL-as-ontology model, `github.com/org/repo/issues?page=2` probably shouldn't be a separate item from `github.com/org/repo/issues`.
-
-**Proposed policy:**
-
-1. **Generic stripping of known tracking params** in `normalize_http_identity_url`:
- ```
- utm_source, utm_medium, utm_campaign, utm_term, utm_content,
- fbclid, gclid, ref, ref_src, ref_cta, ref_loc,
- si (YouTube share tracking)
- ```
-
-2. **Generic stripping of pagination params** (when not identity-bearing):
- ```
- page, per_page, offset, limit, cursor, after, before
- ```
- This is aggressive — resolver `normalize()` can re-add them if a domain treats pagination as identity.
-
-3. **Preserve all other query params** and sort them (already done).
-
-4. **Per-domain overrides** via `DomainResolver::normalize()`:
- - GitHub: strip `?tab=…` on repo pages (just UI state).
- - YouTube: preserve `v`, `list`; strip `t` (timestamp), `si`, `pp`, `feature`.
- - Let each resolver declare which params are identity-bearing vs noise.
-
-### 5. Breadcrumbs for URL structures
-
-**Current state:** `ExternalOntologyPath` splits the stored `https://host/path` into segments for breadcrumbs. Each segment links to `/
… preview truncated; 10,654 characters omittedB — c_554c5efe648b (tommy-mor)
message
[e4908910] consolidated
diff preview
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<RankRow>,
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<String> {
+ 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<String> {
+ 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<String> {
- 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<String> {
- 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<Url, u32> = 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<Url, &str> = 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();
+
… preview truncated; 4,257 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.