{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[529cc941] Replace autocomplete parser with paste-and-go navigate.\n\nThe keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/AGENTS.md b/AGENTS.md\nindex 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644\n--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki\n - **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.\n - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).\n - **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.\n-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e \"(com.microsoft.playwright.CLI/main (into-array String [\\\"install\\\" \\\"chromium\\\" \\\"--with-deps\\\"]))\"`. The browser binary is cached under `~/.cache/ms-playwright`.\n \n ### Commands (see also `TEST.sh`)\n \ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -8,7 +8,7 @@ use std::collections::HashMap;\n use crate::{\n html::{js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n- parser_render::parser_panel_morph,\n+ parser_render::navigate_panel,\n state::AppState,\n ui_action::{parse_html_ui_from_form, HtmlUiAction},\n };\n@@ -57,18 +57,23 @@ pub async fn post_ui_html(\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n }\n- HtmlUiAction::ParseQuery { query } => {\n- let action = parse_reddit_url(&query);\n- let panel = parser_panel_morph(&query, &action);\n- let mut js = JsBuilder::new().morph_selector(\"#parser-panel\", panel);\n- if let Some(comp) = action.primary_completion() {\n- js = js.raw(&format!(\n- \"var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}\",\n- js_string_literal(comp)\n- ));\n+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {\n+ Ok(subreddit) => {\n+ let dest = format!(\"/?sub={subreddit}\");\n+ JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(&dest)\n+ ))\n+ .into_response()\n }\n- js.into_response()\n- }\n+ Err(message) => {\n+ let panel = navigate_panel(&query, Some(&message));\n+ JsBuilder::new()\n+ .morph_selector(\"#parser-panel\", panel)\n+ .into_response()\n+ }\n+ },\n }\n }\n \ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -11,8 +11,7 @@ use serde::Deserialize;\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::ParserAction,\n- parser_render::parser_panel,\n+ parser_render::navigate_panel,\n ranking::{top_bottom, RankedItem},\n reducer::GroupState,\n state::{normalize_scope, AppState},\n@@ -336,10 +335,9 @@ pub async fn home(\n let empty = GroupState::new();\n let group = groups.get(&scope).unwrap_or(&empty);\n \n- let empty_action = ParserAction::suggest(String::new(), None);\n let body = html! {\n h1 { \"sorter2\" }\n- (parser_panel(\"\", &empty_action))\n+ (navigate_panel(\"\", None))\n (vote_panel(&scope))\n (ranking_panel(&scope, group))\n };\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,7 +4,6 @@ pub mod events;\n pub mod form_template;\n pub mod html;\n pub mod parser;\n-pub mod parser_action;\n pub mod parser_render;\n pub mod path_types;\n pub mod ranking;\ndiff --git a/server/src/parser.rs b/server/src/parser.rs\nindex ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644\n--- a/server/src/parser.rs\n+++ b/server/src/parser.rs\n@@ -1,1811 +1,87 @@\n-use std::collections::HashMap;\n-use std::sync::OnceLock;\n+//! Extract a subreddit name from a pasted Reddit URL or path.\n \n-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};\n-\n-// --- Core Abstractions ---\n-\n-/// Unique identifier for nodes in the graph\n-type NodeId = &'static str;\n-\n-/// Pattern matching for edges\n-#[derive(Debug, Clone)]\n-pub enum EdgePattern {\n- /// Matches exact literal string\n- Literal(&'static str),\n- \n- /// Matches any prefix of a string and suggests the full string\n- /// e.g., PrefixOf(\"reddit.com\") matches \"r\", \"re\", \"red\", \"reddit\", \"reddit.com\"\n- PrefixOf(&'static str),\n- \n- /// Captures a variable segment (e.g., subreddit name, username)\n- Variable(&'static str),\n- \n- /// Matches any string (wildcard)\n- Any,\n-}\n-\n-impl EdgePattern {\n- /// Try to match this pattern against input, return (consumed_chars, captured_value)\n- fn matches(&self, input: &str) -> Option<(usize, Option)> {\n- match self {\n- EdgePattern::Literal(lit) => {\n- if input.starts_with(lit) {\n- Some((lit.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::PrefixOf(target) => {\n- // Check if input is a prefix of target\n- if target.starts_with(input) && !input.is_empty() {\n- // It's a valid prefix\n- Some((input.len(), None))\n- } else if input.starts_with(target) {\n- // Full match\n- Some((target.len(), None))\n- } else {\n- None\n- }\n- }\n- EdgePattern::Variable(var_name) => {\n- // Consume until next '/' or end of string\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- let captured = input[..end].to_string();\n- // Validate based on variable type\n- if is_valid_variable(var_name, &captured) {\n- Some((end, Some(captured)))\n- } else {\n- None\n- }\n- } else {\n- None\n- }\n- }\n- EdgePattern::Any => {\n- // Match everything until next '/' or end\n- let end = input.find('/').unwrap_or(input.len());\n- if end > 0 {\n- Some((end, Some(input[..end].to_string())))\n- } else {\n- None\n- }\n- }\n- }\n- }\n- \n- /// Get the completion suggestion for this pattern\n- fn completion(&self, partial: &str) -> Option {\n- match self {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(partial) && partial != *target {\n- Some(target.to_string())\n- } else {\n- None\n- }\n- }\n- _ => None,\n- }\n+pub fn parse_reddit_url(query: &str) -> Result {\n+ let q = query.trim();\n+ if q.is_empty() {\n+ return Err(\"Paste a Reddit URL or r/subreddit path\".into());\n }\n-}\n \n-/// Edge in the graph\n-pub struct Edge {\n- pattern: EdgePattern,\n- target: NodeId,\n- /// Optional description for autocomplete\n- description: Option<&'static str>,\n-}\n-\n-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).\n-type Handler = Box) -> ParserAction + Send + Sync>;\n-\n-/// Node in the graph\n-pub struct Node {\n- #[allow(dead_code)]\n- id: NodeId,\n- edges: Vec,\n- handler: Option,\n-}\n-\n-/// The composable parser graph (immutable after `build`).\n-pub struct Graph {\n- nodes: HashMap,\n- root: NodeId,\n-}\n-\n-// --- Graph Builder (Fluent API) ---\n-\n-pub struct GraphBuilder {\n- nodes: HashMap,\n- current_node: Option,\n- root: NodeId,\n-}\n-\n-impl GraphBuilder {\n- pub fn new() -> Self {\n- let mut nodes = HashMap::new();\n- nodes.insert(\n- \"root\",\n- Node {\n- id: \"root\",\n- edges: Vec::new(),\n- handler: None,\n- },\n- );\n-\n- GraphBuilder {\n- nodes,\n- current_node: Some(\"root\"),\n- root: \"root\",\n- }\n+ if let Some(sub) = subreddit_after_prefix(q, \"r/\") {\n+ return Ok(sub);\n }\n \n- /// Select a node to add edges to\n- pub fn at(mut self, node_id: NodeId) -> Self {\n- self.nodes.entry(node_id).or_insert_with(|| Node {\n- id: node_id,\n- edges: Vec::new(),\n- handler: None,\n- });\n- self.current_node = Some(node_id);\n- self\n+ if let Some(sub) = subreddit_from_path_segment(q, \"/r/\") {\n+ return Ok(sub);\n }\n- \n- /// Add an edge from the current node\n- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {\n- self.edge_with_desc(pattern, target, None)\n- }\n- \n- /// Add an edge with description\n- pub fn edge_with_desc(\n- mut self,\n- pattern: EdgePattern,\n- target: NodeId,\n- desc: Option<&'static str>,\n- ) -> Self {\n- let current = self.current_node.expect(\"No current node selected\");\n-\n- self.nodes.entry(target).or_insert_with(|| Node {\n- id: target,\n- edges: Vec::new(),\n- handler: None,\n- });\n-\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.edges.push(Edge {\n- pattern,\n- target,\n- description: desc,\n- });\n- }\n \n- self\n- }\n-\n- /// Set handler for current node\n- pub fn handler(mut self, handler: F) -> Self\n- where\n- F: Fn(&str, &str, &HashMap) -> ParserAction + Send + Sync + 'static,\n- {\n- let current = self.current_node.expect(\"No current node selected\");\n- if let Some(node) = self.nodes.get_mut(current) {\n- node.handler = Some(Box::new(handler));\n- }\n- self\n- }\n- \n- /// Build the final graph\n- pub fn build(self) -> Graph {\n- Graph {\n- nodes: self.nodes,\n- root: self.root,\n- }\n- }\n+ Err(\"Could not find a subreddit in that URL\".into())\n }\n \n-// --- Parser Implementation ---\n-\n-impl Graph {\n- pub fn parse(&self, input: &str) -> ParserAction {\n- let normalized = input.trim().to_lowercase();\n- let mut state = ParserState {\n- input: &normalized,\n- cursor: 0,\n- current_node_id: self.root,\n- context: HashMap::new(),\n- original_query: input.to_string(),\n- current_prefix: String::new(),\n- };\n- \n- self.parse_recursive(&mut state)\n- }\n- \n- fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {\n- let node = self\n- .nodes\n- .get(state.current_node_id)\n- .expect(\"Node not found in graph\");\n-\n- // If we've consumed all input, check for handler or suggestions\n- if state.cursor >= state.input.len() {\n- if let Some(handler) = &node.handler {\n- return handler(&state.original_query, &state.current_prefix, &state.context);\n- }\n-\n- // No handler, try to suggest based on available edges\n- return self.suggest_from_edges(node, state);\n- }\n-\n- let remaining = &state.input[state.cursor..];\n-\n- // Try to match each edge\n- for edge in &node.edges {\n- if let Some((consumed, captured)) = edge.pattern.matches(remaining) {\n- // Save state for potential backtracking\n- let saved_cursor = state.cursor;\n- let saved_node = state.current_node_id;\n- let saved_prefix = state.current_prefix.clone();\n- \n- // Update state\n- state.cursor += consumed;\n- state.current_node_id = edge.target;\n- state.current_prefix.push_str(&remaining[..consumed]);\n- \n- // Store captured variable if any\n- if let Some(value) = captured {\n- if let EdgePattern::Variable(var_name) = &edge.pattern {\n- state.context.insert(var_name.to_string(), value);\n- }\n- }\n- \n- // Check if this is a partial match that needs completion\n- if state.cursor == state.input.len() {\n- if let Some(completion_suffix) = edge.pattern.completion(remaining) {\n- // Use the current_prefix plus the completion suffix\n- let full_completion = format!(\"{}{}\", \n- state.current_prefix, \n- completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)\n- );\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- \n- // Continue parsing from the target node\n- let result = self.parse_recursive(state);\n- \n- // If we got a valid response, return it\n- if !matches!(result, ParserAction::ShowError(_)) {\n- return result;\n- }\n- \n- // Otherwise, restore state and try next edge\n- state.cursor = saved_cursor;\n- state.current_node_id = saved_node;\n- state.current_prefix = saved_prefix;\n- }\n- }\n- \n- // No edges matched - try to provide suggestions\n- self.suggest_from_edges(node, state)\n- }\n- \n- fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> ParserAction {\n- let remaining = &state.input[state.cursor..];\n- \n- // Find edges that could match with more input\n- for edge in &node.edges {\n- match &edge.pattern {\n- EdgePattern::PrefixOf(target) => {\n- if target.starts_with(remaining) && !remaining.is_empty() {\n- // Use current_prefix instead of rebuilding from input\n- let full_completion = format!(\"{}{}\", state.current_prefix, target);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- EdgePattern::Literal(lit) => {\n- if lit.starts_with(remaining) && !remaining.is_empty() {\n- let full_completion = format!(\"{}{}\", state.current_prefix, lit);\n- return ParserAction::suggest(\n- state.original_query.clone(),\n- Some(Suggestion {\n- text: full_completion.clone(),\n- completion: full_completion,\n- description: edge.description.map(|d| d.to_string()),\n- score: 1.0,\n- })\n- );\n- }\n- }\n- _ => {}\n- }\n- }\n- \n- ParserAction::error(\n- \"InvalidPath\".to_string(),\n- format!(\"'{}' doesn't match any known pattern\", state.original_query)\n- )\n- }\n+fn subreddit_after_prefix(text: &str, prefix: &str) -> Option {\n+ let rest = text.strip_prefix(prefix)?;\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-struct ParserState<'a> {\n- input: &'a str,\n- cursor: usize,\n- current_node_id: NodeId,\n- context: HashMap,\n- original_query: String,\n- current_prefix: String,\n+fn subreddit_from_path_segment(text: &str, needle: &str) -> Option {\n+ let idx = text.find(needle)?;\n+ let rest = &text[idx + needle.len()..];\n+ let sub = rest.split(['/', '?', '#']).next()?.trim();\n+ valid_subreddit(sub)\n }\n \n-// --- Helper Functions ---\n-\n-fn is_valid_variable(var_name: &str, value: &str) -> bool {\n- match var_name {\n- \"subreddit\" => {\n- !value.is_empty() && \n- value.len() <= 21 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_')\n- }\n- \"username\" => {\n- !value.is_empty() && \n- value.len() <= 20 && \n- value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')\n- }\n- \"post_id\" => {\n- !value.is_empty() && \n- value.len() <= 10 && \n- value.chars().all(|c| c.is_alphanumeric())\n- }\n- _ => true, // Allow any value for unknown variables\n+fn valid_subreddit(name: &str) -> Option {\n+ if name.is_empty() {\n+ return None;\n+ }\n+ if name\n+ .chars()\n+ .all(|c| c.is_ascii_alphanumeric() || c == '_')\n+ {\n+ Some(name.to_ascii_lowercase())\n+ } else {\n+ None\n }\n-}\n-\n-// --- Define the Reddit Graph ---\n-\n-pub fn build_reddit_graph() -> Graph {\n- GraphBuilder::new()\n- // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===\n- .at(\"root\")\n- // Direct aliases to subreddit and user selection\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"r/\"),\n- \"subreddit_selection\",\n- Some(\"Browse subreddits (e.g., r/programming)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"u/\"),\n- \"user_selection\",\n- Some(\"Browse users (e.g., u/spez)\")\n- )\n- \n- // Reddit shortcuts - one pattern handles ALL prefixes!\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"), \n- \"reddit_domain\",\n- Some(\"Go to Reddit\")\n- )\n- \n- // Protocol patterns - \"h\" can suggest \"https://\"\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"https://\"),\n- \"https_protocol\",\n- Some(\"HTTPS protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"http://\"),\n- \"http_protocol\", \n- Some(\"HTTP protocol\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"www_prefix\",\n- Some(\"World Wide Web\")\n- )\n- \n-\n- \n- // === HTTPS PROTOCOL: Can go to any domain ===\n- .at(\"https_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTPS)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"https_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTP PROTOCOL: Similar to HTTPS ===\n- .at(\"http_protocol\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit (HTTP)\")\n- )\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"www.\"),\n- \"http_www\",\n- Some(\"WWW prefix\")\n- )\n- \n- // === HTTPS + WWW ===\n- .at(\"https_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === HTTP + WWW ===\n- .at(\"http_www\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === WWW PREFIX (without protocol) ===\n- .at(\"www_prefix\")\n- .edge_with_desc(\n- EdgePattern::PrefixOf(\"reddit.com\"),\n- \"reddit_domain\",\n- Some(\"Reddit\")\n- )\n- \n- // === REDDIT DOMAIN: Expect \"/\" ===\n- .at(\"reddit_domain\")\n- .edge(EdgePattern::Literal(\"/\"), \"reddit_root\")\n- .handler(|query, prefix, _ctx| {\n- // If someone just types \"reddit.com\" (or with protocol) without slash\n- // Suggest adding the slash using the current prefix\n- let completion = format!(\"{}/\", prefix);\n- \n- ParserAction::suggest(\n- query.to_string(),\n- Some(Suggestion {\n- text: completion.clone(),\n- completion,\n- description: Some(\"Continue to Reddit homepage\".to_string()),\n- score: 1.0,\n- })\n- )\n- })\n- \n- // === REDDIT ROOT: The main Reddit navigation ===\n- .at(\"reddit_root\")\n- .edge(EdgePattern::Literal(\"r/\"), \"subreddit_selection\")\n- .edge(EdgePattern::Literal(\"u/\"), \"user_selection\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}r/\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}u/\", prefix),\n- },\n- ],\n- 1400, // 1.4 second interval (slower)\n- true // loop through\n- ),\n- ParserAction::guide(\n- query.to_string(),\n- \"Welcome to Sorter for Reddit\".to_string(),\n- \"Where would you like to start?\".to_string(),\n- vec![\n- GuideOption { \n- key: \"r\".to_string(), \n- label: \"Sort a Subreddit\".to_string(), \n- description: \"Find the best posts in a community.\".to_string(), \n- completion: format!(\"{}r/\", prefix)\n- },\n- GuideOption { \n- key: \"u\".to_string(), \n- label: \"Sort User Content\".to_string(), \n- description: \"Explore and rank a user's posts and comments.\".to_string(), \n- completion: format!(\"{}u/\", prefix)\n- },\n- ]\n- ),\n- ])\n- })\n- \n-\n- \n- // === SUBREDDIT SELECTION: THE UNIFIED NODE ===\n- // This node is now reached from `r/` OR `reddit.com/r/`\n- .at(\"subreddit_selection\")\n- .edge(EdgePattern::Variable(\"subreddit\"), \"subreddit_page\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::multiple(vec![\n- ParserAction::scrolling_suggestions(\n- query.to_string(),\n- vec![\n- ScrollingSuggestion {\n- completion: format!(\"{}programming\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}askreddit\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}aww\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}rust\", prefix),\n- },\n- ScrollingSuggestion {\n- completion: format!(\"{}webdev\", prefix),\n- },\n- ],\n- 1600, // 1.6 second interval (slower)\n- true // loop through\n- ),\n- // Live DB-backed suggestions for subreddits as the user types\n- ParserAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }\n- ])\n- })\n- \n- // === Specific subreddit page ===\n- .at(\"subreddit_page\")\n- .edge(EdgePattern::Literal(\"/\"), \"subreddit_slash\")\n- .handler(|_query, prefix, ctx| {\n- // Use the new unified subreddit resolution logic\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::ResolveAndDisplaySubreddit {\n- subreddit,\n- prefix: prefix.to_string(),\n- }\n- })\n- \n- .at(\"subreddit_slash\")\n- .edge(EdgePattern::Literal(\"hot\"), \"subreddit_hot\")\n- .edge(EdgePattern::Literal(\"top\"), \"subreddit_top\")\n- .edge(EdgePattern::Literal(\"new\"), \"subreddit_new\")\n- .edge(EdgePattern::Literal(\"comments\"), \"subreddit_comments\")\n- .handler(|query, prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::guide(\n- query.to_string(), \n- format!(\"What to sort in r/{}?\", subreddit), \n- \"Choose a category to begin sorting.\".to_string(), \n- vec![\n- GuideOption { \n- key: \"hot\".to_string(), \n- label: \"Hot Posts\".to_string(), \n- description: \"Import and sort posts currently on the front page.\".to_string(), \n- completion: format!(\"{}hot\", prefix) \n- },\n- GuideOption { \n- key: \"top\".to_string(), \n- label: \"Top Posts\".to_string(), \n- description: \"Import and sort the highest-rated posts.\".to_string(), \n- completion: format!(\"{}top\", prefix) \n- },\n- GuideOption { \n- key: \"new\".to_string(), \n- label: \"New Posts\".to_string(), \n- description: \"Import and sort the newest posts.\".to_string(), \n- completion: format!(\"{}new\", prefix) \n- },\n- GuideOption { \n- key: \"comments\".to_string(), \n- label: \"All Comments\".to_string(), \n- description: \"Find the best comment across all imported threads.\".to_string(), \n- completion: format!(\"{}comments/\", prefix) \n- },\n- ]\n- )\n- })\n- \n-\n- \n- .at(\"user_selection\")\n- .edge(EdgePattern::Variable(\"username\"), \"user_profile\")\n- .handler(|query, prefix, _ctx| {\n- ParserAction::guide(\n- query.to_string(),\n- \"User Profile Sorting\".to_string(),\n- \"Enter a Reddit username to sort their content.\".to_string(),\n- vec![\n- GuideOption {\n- key: \"popular\".to_string(),\n- label: \"Popular Users\".to_string(),\n- description: \"Browse well-known Reddit users.\".to_string(),\n- completion: prefix.to_string(),\n- },\n- ]\n- )\n- })\n- \n- // === Subreddit sort types ===\n- .at(\"subreddit_hot\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_top\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_new\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- .at(\"subreddit_comments\")\n- .handler(|_query, _prefix, ctx| {\n- let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.subreddit\".to_string(),\n- pk: subreddit,\n- }\n- })\n- \n- // === User profile ===\n- .at(\"user_profile\")\n- .handler(|_query, _prefix, ctx| {\n- let username = ctx.get(\"username\").cloned().unwrap_or_default();\n- ParserAction::RenderEntityView {\n- ns: \"reddit.user\".to_string(),\n- pk: username,\n- }\n- })\n- \n- // === Build the graph ===\n- .build()\n-}\n-\n-// --- Public API ---\n-\n-static REDDIT_GRAPH: OnceLock = OnceLock::new();\n-\n-fn reddit_graph() -> &'static Graph {\n- REDDIT_GRAPH.get_or_init(build_reddit_graph)\n-}\n-\n-/// Parse a query string and return a UI action\n-pub fn parse_reddit_url(query: &str) -> ParserAction {\n- reddit_graph().parse(query)\n }\n \n #[cfg(test)]\n mod tests {\n use super::*;\n \n- /// Represents a keystroke action\n- #[derive(Debug, Clone, PartialEq)]\n- enum KeyAction {\n- Type(String), // Type characters\n- Tab, // Press tab (accept completion)\n-\n- }\n-\n- /// Expected state after a keystroke\n- #[derive(Debug, Clone)]\n- enum ExpectedAction {\n- Suggestion { completion: String },\n- ScrollingSuggestions { completions: Vec },\n- Guide { title_contains: String },\n-\n- RenderSubreddit { subreddit: String, sort: Option },\n- RenderSubredditComments { subreddit: String },\n- RenderUser { username: String },\n- ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution\n- Error { error_type: String },\n- Multiple { expected_actions: Vec }, // Multiple responses with specific expectations\n- MultipleAny, // Multiple responses (any sub-actions - legacy)\n- DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions\n-\n- }\n-\n- impl ExpectedAction {\n- fn matches(&self, action: &ParserAction) -> bool {\n- match (self, action) {\n- (ExpectedAction::Suggestion { completion }, ParserAction::ShowSuggestions(data)) => {\n- data.suggestion.as_ref()\n- .map(|s| s.completion == *completion)\n- .unwrap_or(false)\n- }\n- (ExpectedAction::ScrollingSuggestions { completions }, ParserAction::ShowScrollingSuggestions { suggestions, .. }) => {\n- let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();\n- *completions == actual_completions\n- }\n- (ExpectedAction::Guide { title_contains }, ParserAction::ShowStaticGuide { title, .. }) => {\n- title.contains(title_contains)\n- }\n- (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderSubredditComments { subreddit }, \n- ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderUser { username }, ParserAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.user\" && pk == username\n- }\n- (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n- ParserAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n- s == subreddit && p == prefix\n- }\n- (ExpectedAction::Error { error_type }, ParserAction::ShowError(data)) => {\n- data.error_type == *error_type\n- }\n- (ExpectedAction::Multiple { expected_actions }, ParserAction::ShowMultiple { actions }) => {\n- // Check that all expected actions are present\n- if expected_actions.len() != actions.len() {\n- return false;\n- }\n- expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {\n- expected.matches(actual)\n- })\n- }\n- (ExpectedAction::MultipleAny, ParserAction::ShowMultiple { .. }) => true,\n- (ExpectedAction::DbSuggestions { partial, prefix }, \n- ParserAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {\n- p == partial && pr == prefix\n- }\n-\n- _ => false,\n- }\n- }\n- }\n-\n- /// Test helper to simulate a sequence of keystrokes\n- fn simulate_keystrokes(actions: Vec) -> Vec<(String, ParserAction)> {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- let mut results = Vec::new();\n- \n- for action in actions {\n- match action {\n- KeyAction::Type(text) => {\n- current_text.push_str(&text);\n- let result = graph.parse(¤t_text);\n- results.push((current_text.clone(), result));\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion if there is one\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(ref data) = result {\n- if let Some(ref suggestion) = data.suggestion {\n- current_text = suggestion.completion.clone();\n- let new_result = graph.parse(¤t_text);\n- results.push((current_text.clone(), new_result));\n- }\n- }\n- }\n-\n- }\n- }\n- \n- results\n- }\n-\n- /// Test a flow using declarative (KeyAction, ExpectedAction) tuples\n- fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {\n- let graph = build_reddit_graph();\n- let mut current_text = String::new();\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- \n- for (i, (key_action, expected)) in flow.iter().enumerate() {\n- // Perform the keystroke\n- match key_action {\n- KeyAction::Type(text) => {\n- current_text.push_str(text);\n- }\n- KeyAction::Tab => {\n- // Tab accepts the current suggestion\n- let result = graph.parse(¤t_text);\n- if let ParserAction::ShowSuggestions(data) = result {\n- if let Some(suggestion) = &data.suggestion {\n- current_text = suggestion.completion.clone();\n- }\n- }\n- }\n- }\n- \n- // Check the result\n- let actual_action = graph.parse(¤t_text);\n- \n- println!(\" Step {}: {:?} -> '{}' -> {:?}\", \n- i + 1, key_action, current_text, actual_action);\n- \n- assert!(\n- expected.matches(&actual_action),\n- \"Flow '{}' failed at step {}\\n Expected: {:?}\\n Actual: {:?}\\n Text: '{}'\",\n- name, i + 1, expected, actual_action, current_text\n- );\n- }\n- \n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n- \n- /// Legacy helper for backward compatibility (will be removed)\n- fn assert_flow(\n- name: &str,\n- actions: Vec,\n- expected_checks: Vec bool>>,\n- ) {\n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Flow: {} ===\", name);\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}' -> {:?}\", i + 1, text, action);\n- \n- if i < expected_checks.len() {\n- let check = &expected_checks[i];\n- assert!(\n- check(text, action),\n- \"Flow '{}' failed at step {} with text '{}' and action {:?}\",\n- name, i + 1, text, action\n- );\n- }\n- }\n- println!(\"✓ Flow '{}' passed!\", name);\n- }\n-\n- #[test]\n- fn test_https_reddit_tab_flow() {\n- // Test: typing \"https://reddit.com\" and pressing tab should give \"https://reddit.com/\"\n- assert_flow(\n- \"HTTPS Reddit with Tab\",\n- vec![\n- KeyAction::Type(\"https://reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // After typing \"https://reddit.com\", should get a suggestion\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com/\" and show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_quick_subreddit_flow() {\n- // Test: \"r\" -> TAB -> \"rust\" (direct alias flow)\n- assert_flow(\n- \"Quick Subreddit Access\",\n- vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"rust\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"r\" should suggest \"r/\"\n- text == \"r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"r/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"r/\" and show subreddit selection\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"r/rust\" should resolve the subreddit\n- text == \"r/rust\" && matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"rust\" && prefix == \"r/rust\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_progressive_completion_flow() {\n- // Test progressive typing: \"r\" -> \"re\" -> \"red\" -> \"redd\" -> \"reddit\" -> TAB\n- let progressive_actions = vec![\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Type(\"e\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"d\".to_string()),\n- KeyAction::Type(\"i\".to_string()),\n- KeyAction::Type(\"t\".to_string()),\n- KeyAction::Tab,\n- ];\n- \n- let results = simulate_keystrokes(progressive_actions);\n- \n- println!(\"\\n=== Progressive Completion Flow ===\");\n- for (i, (text, action)) in results.iter().enumerate() {\n- println!(\" '{}' -> {:?}\", text, action);\n- \n- // First step: \"r\" should suggest \"r/\"\n- if i == 0 && text == \"r\" {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"r/\",\n- \"Should suggest r/ at 'r'\"\n- );\n- }\n- _ => panic!(\"Expected suggestion at 'r'\"),\n- }\n- }\n- // Other steps before tab should suggest \"reddit.com\"\n- else if i > 0 && i < results.len() - 1 {\n- match action {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(\n- data.suggestion.as_ref().unwrap().completion,\n- \"reddit.com\",\n- \"Should suggest reddit.com at '{}'\", text\n- );\n- }\n- _ => panic!(\"Expected suggestion at '{}'\", text),\n- }\n- }\n- }\n- \n- // After tab, should have \"reddit.com\"\n- let (final_text, _) = results.last().unwrap();\n- assert_eq!(final_text, \"reddit.com\");\n- println!(\"✓ Progressive completion flow passed!\");\n- }\n-\n #[test]\n- fn test_subreddit_sort_flow() {\n- // Test navigating to a subreddit and choosing a sort option\n- assert_flow(\n- \"Subreddit Sort Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/r/programming/hot\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/r/programming/hot\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n+ fn parses_short_path() {\n+ assert_eq!(parse_reddit_url(\"r/rust\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_user_profile_flow() {\n- // Test navigating to a user profile\n- assert_flow(\n- \"User Profile Navigation\",\n- vec![\n- KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/u/spez\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n- }),\n- ],\n- );\n+ fn parses_path_with_trailing_slash() {\n+ assert_eq!(parse_reddit_url(\"r/rust/\").unwrap(), \"rust\");\n }\n \n #[test]\n- fn test_alias_shortcut_flow() {\n- // Test using the \"r/\" shortcut - now correctly goes directly to subreddit\n- assert_flow(\n- \"Alias Shortcut\",\n- vec![\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"technology\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- text == \"r/technology\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n- }),\n- ],\n+ fn parses_full_url() {\n+ assert_eq!(\n+ parse_reddit_url(\"https://www.reddit.com/r/programming/hot\").unwrap(),\n+ \"programming\"\n );\n }\n \n #[test]\n- fn test_www_prefix_flow() {\n- // Test with www prefix\n- assert_flow(\n- \"WWW Prefix\",\n- vec![\n- KeyAction::Type(\"www.reddit.com\".to_string()),\n- KeyAction::Tab,\n- ],\n- vec![\n- Box::new(|text, action| {\n- // Should suggest adding slash\n- text == \"www.reddit.com\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"www.reddit.com/\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should show guide\n- text == \"www.reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- ],\n+ fn parses_url_without_scheme() {\n+ assert_eq!(\n+ parse_reddit_url(\"reddit.com/r/AskReddit\").unwrap(),\n+ \"askreddit\"\n );\n }\n \n #[test]\n- fn test_invalid_path_handling() {\n- // Test that invalid paths show errors\n- assert_flow(\n- \"Invalid Path\",\n- vec![\n- KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- text == \"reddit.com/invalid/path\" && \n- matches!(action, ParserAction::ShowError(_))\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_complete_user_journey() {\n- // Test a complete user journey: type partial URL, tab complete, navigate to subreddit\n- assert_flow(\n- \"Complete User Journey\",\n- vec![\n- KeyAction::Type(\"http\".to_string()),\n- KeyAction::Type(\"s://r\".to_string()),\n- KeyAction::Tab,\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"r/\".to_string()),\n- KeyAction::Type(\"programming\".to_string()),\n- KeyAction::Type(\"/\".to_string()),\n- KeyAction::Type(\"top\".to_string()),\n- ],\n- vec![\n- Box::new(|text, action| {\n- // \"http\" should suggest \"https://\"\n- text == \"http\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // \"https://r\" should suggest \"https://reddit.com\"\n- text == \"https://r\" && matches!(action, ParserAction::ShowSuggestions(data) if \n- data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com\").unwrap_or(false)\n- )\n- }),\n- Box::new(|text, action| {\n- // After tab, should have \"https://reddit.com\"\n- text == \"https://reddit.com\" && matches!(action, ParserAction::ShowSuggestions(_))\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/\" should show guide\n- text == \"https://reddit.com/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/\" should show subreddit selection\n- text == \"https://reddit.com/r/\" && matches!(action, ParserAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming\" should resolve subreddit\n- text == \"https://reddit.com/r/programming\" && \n- matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } \n- if subreddit == \"programming\" && prefix == \"https://reddit.com/r/programming\")\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/\" should show sort options\n- text == \"https://reddit.com/r/programming/\" && \n- matches!(action, ParserAction::ShowStaticGuide { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/programming/top\" should render entity view\n- text == \"https://reddit.com/r/programming/top\" && \n- matches!(action, ParserAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\n- }\n-\n- #[test]\n- fn test_multiple_tab_completions() {\n- // Test multiple tab completions in sequence\n- let actions = vec![\n- KeyAction::Type(\"h\".to_string()),\n- KeyAction::Tab, // Complete to \"https://\"\n- KeyAction::Type(\"r\".to_string()),\n- KeyAction::Tab, // Complete to \"https://reddit.com\"\n- KeyAction::Type(\"/\".to_string()),\n- ];\n- \n- let results = simulate_keystrokes(actions);\n- \n- println!(\"\\n=== Multiple Tab Completions ===\");\n- for (i, (text, _action)) in results.iter().enumerate() {\n- println!(\" Step {}: '{}'\", i + 1, text);\n- }\n- \n- // Verify the final state\n- assert_eq!(results[1].0, \"https://\"); // After first tab\n- assert_eq!(results[3].0, \"https://reddit.com\"); // After second tab\n- assert_eq!(results[4].0, \"https://reddit.com/\"); // After typing /\n- \n- println!(\"✓ Multiple tab completions work correctly!\");\n- }\n-\n- // === CONVENIENCE MACROS FOR CLEANER TESTS ===\n- \n- macro_rules! flow {\n- ($(($key:expr, $expected:expr)),* $(,)?) => {\n- vec![$(($key, $expected)),*]\n- };\n- }\n- \n- macro_rules! type_text {\n- ($text:expr) => {\n- KeyAction::Type($text.to_string())\n- };\n- }\n- \n- macro_rules! suggests {\n- ($completion:expr) => {\n- ExpectedAction::Suggestion { completion: $completion.to_string() }\n- };\n- }\n- \n- macro_rules! renders_subreddit {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }\n- };\n- ($subreddit:expr, $sort:expr) => {\n- ExpectedAction::RenderSubreddit { \n- subreddit: $subreddit.to_string(), \n- sort: Some($sort.to_string()) \n- }\n- };\n- }\n- \n- macro_rules! renders_subreddit_comments {\n- ($subreddit:expr) => {\n- ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }\n- };\n- }\n- \n- macro_rules! resolves_subreddit {\n- ($subreddit:expr, $prefix:expr) => {\n- ExpectedAction::ResolveSubreddit { \n- subreddit: $subreddit.to_string(),\n- prefix: $prefix.to_string()\n- }\n- };\n- }\n- \n- macro_rules! shows_guide {\n- ($title_contains:expr) => {\n- ExpectedAction::Guide { title_contains: $title_contains.to_string() }\n- };\n- }\n- \n- macro_rules! multiple {\n- ($($action:expr),* $(,)?) => {\n- ExpectedAction::Multiple { expected_actions: vec![$($action),*] }\n- };\n- }\n- \n- macro_rules! scrolling_suggestions {\n- ($($completion:expr),* $(,)?) => {\n- ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }\n- };\n- }\n- \n- macro_rules! db_suggestions {\n- ($partial:expr, $prefix:expr) => {\n- ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }\n- };\n- }\n-\n- // === NEW DECLARATIVE TESTS ===\n-\n- #[test]\n- fn test_declarative_https_tab_flow() {\n- test_flow(\"HTTPS Tab Completion\", vec![\n- (KeyAction::Type(\"https://reddit.com\".to_string()), \n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Tab, \n- multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"),\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_quick_subreddit_flow() {\n- test_flow(\"Quick Subreddit Flow\", vec![\n- (KeyAction::Type(\"r\".to_string()), \n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Tab, \n- ExpectedAction::MultipleAny), // r/ shows subreddit selection\n- (KeyAction::Type(\"rust\".to_string()),\n- ExpectedAction::ResolveSubreddit { subreddit: \"rust\".to_string(), prefix: \"r/rust\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_complete_journey() {\n- test_flow(\"Complete User Journey\", vec![\n- (KeyAction::Type(\"http\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://\".to_string() }),\n- (KeyAction::Type(\"s://r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"https://reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n- (KeyAction::Type(\"/r/programming/top\".to_string()),\n- ExpectedAction::RenderSubreddit { \n- subreddit: \"programming\".to_string(), \n- sort: Some(\"top\".to_string()) \n- }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_user_profile() {\n- test_flow(\"User Profile Navigation\", vec![\n- (KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n- ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_error_handling() {\n- test_flow(\"Error Handling\", vec![\n- (KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n- ExpectedAction::Error { error_type: \"InvalidPath\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_progressive_completion() {\n- test_flow(\"Progressive Completion\", vec![\n- (KeyAction::Type(\"r\".to_string()),\n- ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n- (KeyAction::Type(\"e\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"d\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Type(\"dit\".to_string()),\n- ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n- (KeyAction::Tab,\n- ExpectedAction::Suggestion { completion: \"reddit.com/\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_declarative_subreddit_guide() {\n- test_flow(\"Subreddit Guide\", vec![\n- (KeyAction::Type(\"reddit.com/r/programming/\".to_string()),\n- ExpectedAction::Guide { title_contains: \"What to sort\".to_string() }),\n- ]);\n- }\n-\n- #[test]\n- fn test_clean_macro_example() {\n- // This is what the tests can look like with macros!\n- test_flow(\"Clean Macro Example\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- (type_text!(\"rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_unified_subreddit_resolution_flow() {\n- // This test demonstrates the new unified behavior:\n- // - r/programming (exact) → tries exact match first\n- // - r/pro (partial) → tries exact match, then suggestions + TAB completion\n- \n- test_flow(\"Unified Resolution: Exact Match\", flow![\n- (type_text!(\"r/programming\"), resolves_subreddit!(\"programming\", \"r/programming\")),\n- ]);\n- \n- test_flow(\"Unified Resolution: Partial Match\", flow![\n- (type_text!(\"r/pro\"), resolves_subreddit!(\"pro\", \"r/pro\")),\n- ]);\n- \n- // Both go through the same action type, but dispatcher handles them differently:\n- // - If \"programming\" exists in DB → shows EntityView immediately\n- // - If \"pro\" doesn't exist in DB → shows Multiple with:\n- // 1. Suggestions (for TAB completion to best match) \n- // 2. Selection (for clickable options including import)\n- }\n-\n- #[test]\n- fn test_tab_completion_workflow_unified() {\n- // This demonstrates the desired TAB completion behavior:\n- // r/pro + TAB → r/programming (if \"programming\" is the best DB match)\n- \n- // Note: This test shows the PARSER behavior. The actual TAB completion \n- // happens in the frontend when it receives the Multiple response containing\n- // both Suggestions (for TAB) and Selection (for click options).\n- \n- let graph = build_reddit_graph();\n- \n- // 1. Parser generates unified action for partial input\n- match graph.parse(\"r/pro\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"pro\");\n- assert_eq!(prefix, \"r/pro\");\n- println!(\"✓ Parser correctly identifies 'r/pro' as subreddit resolution\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for 'r/pro'\"),\n- }\n- \n- // 2. When dispatcher runs (in real app), it will return Multiple response with:\n- // - Suggestions: { completion: \"r/programming\" } (for TAB)\n- // - Selection: [ \"Import r/pro\", \"r/programming\", ... ] (for clicks)\n- \n- println!(\"✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)\");\n- }\n-\n- #[test]\n- fn test_ultra_clean_user_journey() {\n- test_flow(\"Ultra Clean User Journey\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"ttps://r\"), suggests!(\"https://reddit.com\")), \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- (type_text!(\"/u/spez\"), ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n- ]);\n- }\n-\n- // === TESTS FROM parser.tdsl ===\n-\n- #[test]\n- fn test_tdsl_basic_reddit_progression() {\n- // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps\n- test_flow(\"TDSL Basic Reddit Progression\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- (type_text!(\"e\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"d\"), suggests!(\"reddit.com\")),\n- (type_text!(\"i\"), suggests!(\"reddit.com\")),\n- (type_text!(\"t\"), suggests!(\"reddit.com\")),\n- (type_text!(\".\"), suggests!(\"reddit.com\")),\n- (type_text!(\"c\"), suggests!(\"reddit.com\")),\n- (type_text!(\"o\"), suggests!(\"reddit.com\")),\n- (KeyAction::Tab, suggests!(\"reddit.com/\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_reddit_com_slash_infographic() {\n- // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}\n- test_flow(\"TDSL Reddit.com/ Infographic\", flow![\n- (type_text!(\"reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_selection() {\n- // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n- test_flow(\"TDSL Subreddit Selection\", flow![\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_view() {\n- // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}\n- test_flow(\"TDSL Subreddit View\", flow![\n- (type_text!(\"reddit.com/r/programming\"), resolves_subreddit!(\"programming\", \"reddit.com/r/programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_slash_infographic() {\n- // reddit.com/r/{sub}/ -> {show infographic or something}\n- test_flow(\"TDSL Subreddit Slash Infographic\", flow![\n- (type_text!(\"reddit.com/r/programming/\"), shows_guide!(\"What to sort\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_subreddit_comments() {\n- // reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n- test_flow(\"TDSL Subreddit Comments\", flow![\n- (type_text!(\"reddit.com/r/programming/comments\"), renders_subreddit_comments!(\"programming\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_h_to_https() {\n- // h->https:// (show supported domains)\n- test_flow(\"TDSL H to HTTPS\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_reddit() {\n- // https://r->https://reddit.com/\n- test_flow(\"TDSL Composable HTTPS Reddit\", flow![\n- (type_text!(\"https://r\"), suggests!(\"https://reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www() {\n- // https://w->https://www.\n- test_flow(\"TDSL Composable HTTPS WWW\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_composable_https_www_reddit() {\n- // https://www.r->https://www.reddit.com/\n- test_flow(\"TDSL Composable HTTPS WWW Reddit\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_full_composable_chain() {\n- // Complete chain showing composability: h -> https:// -> https://www.reddit.com\n- test_flow(\"TDSL Full Composable Chain Step 1\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 2\", flow![\n- (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n- ]);\n- \n- test_flow(\"TDSL Full Composable Chain Step 3\", flow![\n- (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_progressive_reddit_paths() {\n- // Test various reddit paths work as expected\n- test_flow(\"TDSL Progressive Reddit Paths\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test] \n- fn test_tdsl_subreddit_sorting_options() {\n- // Test that subreddit sorting options work as described\n- test_flow(\"TDSL Subreddit Sorting\", flow![\n- (type_text!(\"reddit.com/r/rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit Top\", flow![\n- (type_text!(\"reddit.com/r/rust/top\"), renders_subreddit!(\"rust\", \"top\")),\n- ]);\n- \n- test_flow(\"TDSL Subreddit New\", flow![\n- (type_text!(\"reddit.com/r/rust/new\"), renders_subreddit!(\"rust\", \"new\")),\n- ]);\n- }\n-\n- #[test]\n- fn test_tdsl_all_reddit_prefixes() {\n- // Test all the prefixes mentioned in parser.tdsl work\n- // \"r\" now suggests \"r/\", all others suggest \"reddit.com\"\n- let prefixes_to_reddit = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- // Test \"r\" separately since it now suggests \"r/\"\n- test_flow(\"TDSL Prefix: r\", flow![\n- (type_text!(\"r\"), suggests!(\"r/\")),\n- ]);\n- \n- for prefix in prefixes_to_reddit {\n- test_flow(&format!(\"TDSL Prefix: {}\", prefix), flow![\n- (type_text!(prefix), suggests!(\"reddit.com\")),\n- ]);\n- }\n- }\n-\n- #[test]\n- fn test_tdsl_protocol_combinations() {\n- // Test various protocol combinations from parser.tdsl\n- let test_cases = vec![\n- (\"http://r\", \"http://reddit.com\"),\n- (\"https://r\", \"https://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"http://www.r\", \"http://www.reddit.com\"),\n- ];\n- \n- for (input, expected) in test_cases {\n- test_flow(&format!(\"TDSL Protocol: {}\", input), flow![\n- (type_text!(input), suggests!(expected)),\n- ]);\n- }\n+ fn rejects_empty() {\n+ assert!(parse_reddit_url(\"\").is_err());\n+ assert!(parse_reddit_url(\" \").is_err());\n }\n \n #[test]\n- fn test_tdsl_edge_case_completions() {\n- // Test edge cases mentioned in parser.tdsl\n- test_flow(\"TDSL Reddit.com completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- ]);\n- \n- // Test that typing full reddit.com suggests the slash\n- test_flow(\"TDSL Full domain completion\", flow![\n- (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_real_user_trace_2025_08_07_fixed() {\n- // Based on actual WebSocket trace from 2025-08-07T01:35:46Z\n- // This test shows the CORRECT behavior after fixing the protocol preservation bug\n- test_flow(\"Real User Trace: Progressive Typing with Tab Completions (Fixed)\", flow![\n- // User started typing \"h\"\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // User continued to \"ht\" \n- (type_text!(\"t\"), suggests!(\"https://\")),\n- \n- // User finished typing \"https://\" (trace shows full protocol)\n- (type_text!(\"tps://\"), suggests!(\"https://\")),\n- \n- // User started typing \"r\" after protocol\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // User continued typing \"re\"\n- (type_text!(\"e\"), suggests!(\"https://reddit.com\")),\n- \n- // User typed out or completed \"https://reddit.com\"\n- (type_text!(\"ddit.com\"), suggests!(\"https://reddit.com/\")),\n- \n- // User accepted completion to \"https://reddit.com/\"\n- // NOW suggestions should preserve the https:// protocol\n- (KeyAction::Tab, multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // This is the key fix!\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_protocol_preservation_bug_fix() {\n- // This test specifically verifies the fix for the protocol preservation bug\n- test_flow(\"Protocol Preservation: HTTPS Reddit Homepage\", flow![\n- (type_text!(\"https://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // Should preserve https://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that subreddit selection preserves protocol\n- test_flow(\"Protocol Preservation: HTTPS Subreddit Selection\", flow![\n- (type_text!(\"https://reddit.com/r/\"), multiple![\n- scrolling_suggestions!(\"https://reddit.com/r/programming\", \"https://reddit.com/r/askreddit\", \"https://reddit.com/r/aww\", \"https://reddit.com/r/rust\", \"https://reddit.com/r/webdev\"), // Should preserve https://\n- db_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/r/\")\n- ]),\n- ]);\n- \n- // Test with different protocols\n- test_flow(\"Protocol Preservation: HTTP\", flow![\n- (type_text!(\"http://reddit.com/\"), multiple![\n- scrolling_suggestions!(\"http://reddit.com/r/\", \"http://reddit.com/u/\"), // Should preserve http://\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- test_flow(\"Protocol Preservation: WWW\", flow![\n- (type_text!(\"www.reddit.com/\"), multiple![\n- scrolling_suggestions!(\"www.reddit.com/r/\", \"www.reddit.com/u/\"), // Should preserve www.\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- \n- // Test that plain reddit.com still works\n- test_flow(\"Protocol Preservation: Plain Domain\", flow![\n- (type_text!(\"reddit.com/\"), multiple![\n- scrolling_suggestions!(\"reddit.com/r/\", \"reddit.com/u/\"), // No protocol prefix\n- shows_guide!(\"Welcome to Sorter\")\n- ]),\n- ]);\n- }\n-\n- #[test]\n- fn test_user_navigation_pattern() {\n- // Models how users actually navigate: type → tab → click suggestion → end up at destination\n- // This captures the \"jump\" from https://reddit.com/ to reddit.com/r/ seen in the trace\n- test_flow(\"User Navigation: Protocol to Domain\", flow![\n- // User types and gets to homepage\n- (type_text!(\"https://reddit.com/\"), ExpectedAction::MultipleAny),\n- ]);\n- \n- // Then they navigate (perhaps clicking a suggestion) to subreddit selection\n- test_flow(\"User Navigation: Click to Subreddit\", flow![\n- // This is where they ended up - the suggestion in the guide probably said \"reddit.com/r/\"\n- (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- #[test]\n- fn test_progressive_typing_pattern() {\n- // Based on the trace pattern - users often type character by character\n- // This tests the exact sequence of suggestions they would see\n- test_flow(\"Progressive Typing Pattern\", flow![\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- (type_text!(\"t\"), suggests!(\"https://\")), // Still suggests https://\n- (type_text!(\"t\"), suggests!(\"https://\")), // ht -> htt, still suggests https://\n- (type_text!(\"p\"), suggests!(\"https://\")), // http should still suggest https://\n- (type_text!(\"s\"), suggests!(\"https://\")), // https should suggest https://\n- (type_text!(\"://\"), suggests!(\"https://\")), // Even complete protocol still suggests itself\n- ]);\n- }\n-\n- #[test] \n- fn test_tab_completion_workflow() {\n- // Test what happens when user uses tab completions strategically\n- test_flow(\"Strategic Tab Completion Workflow\", flow![\n- // Start typing, get suggestion\n- (type_text!(\"h\"), suggests!(\"https://\")),\n- \n- // Accept suggestion with tab - this should move us to \"https://\"\n- (KeyAction::Tab, suggests!(\"https://\")), // After tab, we're at \"https://\" which still suggests itself\n- \n- // Start typing reddit\n- (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n- \n- // Accept reddit suggestion \n- (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n- \n- // Accept final suggestion to get to homepage\n- (KeyAction::Tab, ExpectedAction::MultipleAny),\n- ]);\n- }\n-\n- // Keep the original tests as well\n- #[test]\n- fn test_reddit_prefix_autocomplete() {\n- let graph = build_reddit_graph();\n- \n- // \"r\" now suggests \"r/\", others suggest \"reddit.com\"\n- match graph.parse(\"r\") {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"r/\");\n- println!(\"✓ 'r' → r/\");\n- }\n- _ => panic!(\"Expected suggestion for 'r'\"),\n- }\n- \n- // Test other prefixes that should suggest \"reddit.com\"\n- let prefixes = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n- \n- for prefix in prefixes {\n- match graph.parse(prefix) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, \"reddit.com\");\n- println!(\"✓ '{}' → reddit.com\", prefix);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", prefix),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_protocol_composition() {\n- let graph = build_reddit_graph();\n- \n- // Test protocol + reddit compositions\n- let tests = vec![\n- (\"https://r\", \"https://reddit.com\"),\n- (\"https://re\", \"https://reddit.com\"),\n- (\"https://reddit\", \"https://reddit.com\"),\n- (\"https://www.r\", \"https://www.reddit.com\"),\n- (\"https://www.reddit\", \"https://www.reddit.com\"),\n- (\"http://r\", \"http://reddit.com\"),\n- (\"www.r\", \"www.reddit.com\"),\n- ];\n- \n- for (input, expected) in tests {\n- match graph.parse(input) {\n- ParserAction::ShowSuggestions(data) => {\n- assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);\n- println!(\"✓ '{}' → {}\", input, expected);\n- }\n- _ => panic!(\"Expected suggestion for '{}'\", input),\n- }\n- }\n- }\n- \n- #[test]\n- fn test_alias_and_full_paths() {\n- let graph = build_reddit_graph();\n- \n- // reddit.com/ should show guide, r/ should show subreddit selection\n- match graph.parse(\"reddit.com/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_guide = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowStaticGuide { .. }));\n- assert!(has_guide, \"Path 'reddit.com/' should show guide\");\n- println!(\"✓ 'reddit.com/' shows Reddit root guide\");\n- }\n- _ => panic!(\"Expected Multiple action for 'reddit.com/'\"),\n- }\n- \n- match graph.parse(\"r/\") {\n- ParserAction::ShowMultiple { actions } => {\n- let has_scrolling_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::ShowScrollingSuggestions { .. }));\n- let has_db_suggestions = actions.iter()\n- .any(|a| matches!(a, ParserAction::SuggestSubredditsFromDb { .. }));\n- assert!(has_scrolling_suggestions && has_db_suggestions, \n- \"Path 'r/' should show scrolling suggestions and DB suggestions\");\n- println!(\"✓ 'r/' shows subreddit selection\");\n- }\n- _ => panic!(\"Expected Multiple action for 'r/'\"),\n- }\n- }\n- \n- #[test]\n- fn test_deep_navigation() {\n- let graph = build_reddit_graph();\n- \n- // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit\n- match graph.parse(\"reddit.com/r/rust\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"rust\");\n- assert_eq!(prefix, \"reddit.com/r/rust\");\n- println!(\"✓ reddit.com/r/rust recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit\"),\n- }\n- \n- // Test with alias (r/ goes directly to subreddit, no double r/)\n- match graph.parse(\"r/programming\") {\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n- assert_eq!(subreddit, \"programming\");\n- assert_eq!(prefix, \"r/programming\");\n- println!(\"✓ r/programming (alias) recognized\");\n- }\n- _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit via alias\"),\n- }\n+ fn rejects_garbage() {\n+ assert!(parse_reddit_url(\"hello world\").is_err());\n }\n }\ndiff --git a/server/src/parser_action.rs b/server/src/parser_action.rs\ndeleted file mode 100644\nindex 2b98556b562d548ce231a726f9e704f595e8f4a1..0000000000000000000000000000000000000000\n--- a/server/src/parser_action.rs\n+++ /dev/null\n@@ -1,124 +0,0 @@\n-//! Parser output actions — graph handlers return these; HTML render turns them into markup.\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct Suggestion {\n- pub text: String,\n- pub completion: String,\n- pub description: Option,\n- pub score: f64,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ScrollingSuggestion {\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct GuideOption {\n- pub key: String,\n- pub label: String,\n- pub description: String,\n- pub completion: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct SuggestionsData {\n- pub query: String,\n- pub suggestion: Option,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub struct ErrorData {\n- pub error_type: String,\n- pub message: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq)]\n-pub enum ParserAction {\n- ShowSuggestions(SuggestionsData),\n- ShowScrollingSuggestions {\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- },\n- ShowStaticGuide {\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- },\n- ShowMultiple {\n- actions: Vec,\n- },\n- ShowError(ErrorData),\n- SuggestSubredditsFromDb {\n- partial: String,\n- prefix: String,\n- },\n- ResolveAndDisplaySubreddit {\n- subreddit: String,\n- prefix: String,\n- },\n- RenderEntityView {\n- ns: String,\n- pk: String,\n- },\n-}\n-\n-impl ParserAction {\n- pub fn suggest(query: String, suggestion: Option) -> Self {\n- Self::ShowSuggestions(SuggestionsData { query, suggestion })\n- }\n-\n- pub fn error(error_type: String, message: String) -> Self {\n- Self::ShowError(ErrorData {\n- error_type,\n- message,\n- })\n- }\n-\n- pub fn multiple(actions: Vec) -> Self {\n- Self::ShowMultiple { actions }\n- }\n-\n- pub fn scrolling_suggestions(\n- query: String,\n- suggestions: Vec,\n- interval_ms: u64,\n- r#loop: bool,\n- ) -> Self {\n- Self::ShowScrollingSuggestions {\n- query,\n- suggestions,\n- interval_ms,\n- r#loop,\n- }\n- }\n-\n- pub fn guide(\n- query: String,\n- title: String,\n- subtitle: String,\n- options: Vec,\n- ) -> Self {\n- Self::ShowStaticGuide {\n- query,\n- title,\n- subtitle,\n- options,\n- }\n- }\n-\n- /// Primary tab-completion string, if any.\n- pub fn primary_completion(&self) -> Option<&str> {\n- match self {\n- Self::ShowSuggestions(data) => data\n- .suggestion\n- .as_ref()\n- .map(|s| s.completion.as_str()),\n- Self::ShowMultiple { actions } => actions.iter().find_map(|a| a.primary_completion()),\n- _ => None,\n- }\n- }\n-}\ndiff --git a/server/src/parser_render.rs b/server/src/parser_render.rs\nindex 05e4f519cc3832392282b319272b61c104eaabf6..f2341afe21476b689a536137798d97277211a962 100644\n--- a/server/src/parser_render.rs\n+++ b/server/src/parser_render.rs\n@@ -2,12 +2,9 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n- parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion},\n ui_action::UI_RPC_FIELD,\n };\n \n-const SAMPLE_SUBREDDITS: &[&str] = &[\"programming\", \"askreddit\", \"rust\", \"webdev\", \"aww\"];\n-\n fn parse_query_rpc_template() -> String {\n template_json_compact(&serde_json::json!({\n \"action\": \"parse_query\",\n@@ -16,193 +13,32 @@ fn parse_query_rpc_template() -> String {\n .expect(\"parse_query rpc template\")\n }\n \n-fn completion_button(completion: &str, label: &str, primary: bool) -> Markup {\n- let class = if primary {\n- \"parser-completion parser-suggestion-primary btn-link\"\n- } else {\n- \"parser-completion btn-link\"\n- };\n- html! {\n- button\n- type=\"button\"\n- class=(class)\n- data-completion=(completion)\n- title=\"Use this path\" {\n- (label)\n- }\n- }\n-}\n-\n-fn render_suggestion(s: &Suggestion) -> Markup {\n- let desc = s\n- .description\n- .as_deref()\n- .unwrap_or(\"Tab to complete\");\n- html! {\n- p class=\"parser-suggestion\" {\n- (completion_button(&s.completion, &s.completion, true))\n- span class=\"muted small\" { \" — \" (desc) }\n- }\n- }\n-}\n-\n-fn render_scrolling(suggestions: &[ScrollingSuggestion]) -> Markup {\n- html! {\n- div class=\"parser-scrolling muted small\" {\n- p { \"Examples:\" }\n- ul class=\"parser-scroll-list\" {\n- @for s in suggestions {\n- li { (completion_button(&s.completion, &s.completion, false)) }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_guide(title: &str, subtitle: &str, options: &[GuideOption]) -> Markup {\n- html! {\n- div class=\"parser-guide\" {\n- h3 { (title) }\n- p class=\"muted\" { (subtitle) }\n- ul class=\"parser-guide-list\" {\n- @for opt in options {\n- li {\n- strong { (opt.key) \": \" }\n- (completion_button(&opt.completion, &opt.label, false))\n- span class=\"muted small\" { \" — \" (opt.description) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_db_subs(partial: &str, prefix: &str) -> Markup {\n- let needle = partial.to_lowercase();\n- let matches: Vec<_> = SAMPLE_SUBREDDITS\n- .iter()\n- .filter(|s| s.contains(&needle) || prefix.ends_with('/') && needle.is_empty())\n- .take(6)\n- .collect();\n- html! {\n- @if !matches.is_empty() {\n- div class=\"parser-db-subs muted small\" {\n- p { \"Subreddits:\" }\n- ul {\n- @for sub in matches {\n- @let completion = format!(\"{prefix}{sub}\");\n- li { (completion_button(&completion, &format!(\"r/{sub}\"), false)) }\n- }\n- }\n- }\n- }\n- }\n-}\n-\n-fn render_action(action: &ParserAction) -> Markup {\n- match action {\n- ParserAction::ShowSuggestions(data) => html! {\n- div class=\"parser-result parser-suggestions\" {\n- @if let Some(s) = &data.suggestion {\n- (render_suggestion(s))\n- } @else {\n- p class=\"muted\" { \"No completion\" }\n- }\n- }\n- },\n- ParserAction::ShowScrollingSuggestions { suggestions, .. } => {\n- render_scrolling(suggestions)\n- }\n- ParserAction::ShowStaticGuide {\n- title,\n- subtitle,\n- options,\n- ..\n- } => render_guide(title, subtitle, options),\n- ParserAction::ShowMultiple { actions } => html! {\n- div class=\"parser-multiple\" {\n- @for a in actions {\n- (render_action(a))\n- }\n- }\n- },\n- ParserAction::ShowError(data) => html! {\n- p class=\"parser-error muted\" {\n- strong { (data.error_type) \": \" }\n- (data.message)\n- }\n- },\n- ParserAction::SuggestSubredditsFromDb { partial, prefix } => {\n- render_db_subs(partial, prefix)\n- }\n- ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => html! {\n- div class=\"parser-resolve\" {\n- p {\n- \"Subreddit \"\n- strong { \"r/\" (subreddit) }\n- @if subreddit.len() <= 3 {\n- span class=\"muted small\" { \" (partial — tab or pick a match)\" }\n- }\n- }\n- p {\n- a class=\"parser-rank-link\"\n- href=(format!(\"/?sub={subreddit}\")) {\n- \"Rank r/\" (subreddit) \" →\"\n- }\n- }\n- (render_db_subs(subreddit, prefix))\n- }\n- },\n- ParserAction::RenderEntityView { ns, pk } => html! {\n- div class=\"parser-entity\" {\n- p {\n- \"Would open \"\n- code { (ns) \"/\" (pk) }\n- }\n- }\n- },\n- }\n-}\n-\n-/// Parser output panel (inner content for `#parser-panel`).\n-pub fn parser_panel(query: &str, action: &ParserAction) -> Markup {\n+/// Navigate panel: paste a Reddit URL and click Go.\n+pub fn navigate_panel(query: &str, error: Option<&str>) -> Markup {\n html! {\n section id=\"parser-panel\" class=\"demo-panel\" {\n h2 { \"Navigate\" }\n p class=\"muted small\" {\n- \"Type a Reddit path — \"\n- code { \"r/rust\" }\n- \", \"\n- code { \"reddit.com/r/programming/hot\" }\n- \", etc. Tab completes; each keystroke posts \"\n- code { \"__rpc__\" }\n- \" to \"\n- code { \"/ui\" }\n- \".\"\n+ \"Paste a Reddit URL or \"\n+ code { \"r/subreddit\" }\n+ \" path, then click Go to rank that subreddit.\"\n }\n form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n- input\n- type=\"text\"\n+ textarea\n name=\"query\"\n id=\"parser-input\"\n- value=(query)\n- placeholder=\"r/ or reddit.com/…\"\n+ rows=\"3\"\n+ placeholder=\"https://reddit.com/r/rust or r/rust\"\n autocomplete=\"off\"\n- spellcheck=\"false\";\n+ spellcheck=\"false\" {\n+ (query)\n+ }\n input type=\"hidden\" name=(UI_RPC_FIELD) value=(parse_query_rpc_template());\n+ button type=\"submit\" class=\"btn-primary\" { \"Go\" }\n }\n- div id=\"parser-output\" {\n- @if query.is_empty() {\n- p class=\"muted\" { \"Start typing…\" }\n- } @else {\n- (render_action(action))\n- }\n+ @if let Some(msg) = error {\n+ p class=\"parser-error muted\" { (msg) }\n }\n }\n }\n }\n-\n-/// Wrap panel HTML for Idiomorph (morph `#parser-panel` only).\n-pub fn parser_panel_morph(query: &str, action: &ParserAction) -> Markup {\n- parser_panel(query, action)\n-}\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 8c357b133599b798a9c06a77a826bcef05a3a407..0e030b3448b8e45acbe49d2de47ea26372445c54 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -22,7 +22,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n scope: String,\n },\n- /// Parse address-bar query via Reddit transition graph; morph `#parser-panel`.\n+ /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n query: String,\n },\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 1e5c3c13d6f7d8f180a4126ba25b44e9801cdb5d..9c475d8e076ba527aaf7a25c177d0d651bd46ca9 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -19,39 +19,6 @@\n }).then(evalJs);\n }\n \n- // Parser responses race: a slow response for an earlier keystroke can arrive\n- // after a newer one and clobber the panel. Tag each request with a monotonic\n- // sequence number and only apply a response if it is newer than the last one\n- // applied, so stale (superseded) responses are discarded.\n- var parserSeq = 0;\n- var parserApplied = 0;\n-\n- function postParserForm(form) {\n- var mySeq = ++parserSeq;\n- return fetch(form.action, {\n- method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n- credentials: 'same-origin',\n- }).then(function (resp) {\n- return resp.text();\n- }).then(function (js) {\n- if (mySeq <= parserApplied) return;\n- parserApplied = mySeq;\n- evalJs(js);\n- });\n- }\n-\n- var parserTimer = null;\n-\n- function scheduleParserInput(input) {\n- if (parserTimer) clearTimeout(parserTimer);\n- parserTimer = setTimeout(function () {\n- var form = document.getElementById('parser-form');\n- if (form) postParserForm(form);\n- }, 120);\n- }\n-\n function initSorterUi() {\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n@@ -67,38 +34,6 @@\n if (firstField) firstField.focus();\n }\n });\n-\n- document.addEventListener('input', function (e) {\n- if (e.target && e.target.id === 'parser-input') {\n- scheduleParserInput(e.target);\n- }\n- });\n-\n- document.addEventListener('keydown', function (e) {\n- if (!e.target || e.target.id !== 'parser-input') return;\n- if (e.key !== 'Tab') return;\n- var completion =\n- e.target.dataset.completion ||\n- (function () {\n- var btn = document.querySelector('#parser-output .parser-suggestion-primary');\n- return btn && btn.getAttribute('data-completion');\n- })();\n- if (!completion) return;\n- e.preventDefault();\n- e.target.value = completion;\n- scheduleParserInput(e.target);\n- });\n-\n- document.addEventListener('click', function (e) {\n- var btn = e.target.closest('.parser-completion');\n- if (!btn) return;\n- var input = document.getElementById('parser-input');\n- if (!input) return;\n- var completion = btn.getAttribute('data-completion');\n- if (!completion) return;\n- input.value = completion;\n- scheduleParserInput(input);\n- });\n }\n \n if (document.readyState === 'loading') {\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex c5fc9e7abf865ed689f58ff0763d3656d73e35f5..6ad0ac712bbc840bedee60f613794de9385fbbd1 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -129,34 +129,12 @@ code {\n background: var(--bg);\n color: var(--fg);\n font-size: 1rem;\n+ font-family: inherit;\n+ resize: vertical;\n }\n \n-.btn-link {\n- background: none;\n- border: none;\n- padding: 0;\n- color: var(--accent);\n- cursor: pointer;\n- font: inherit;\n-}\n-\n-.btn-link:hover {\n- text-decoration: underline;\n-}\n-\n-.parser-rank-link {\n- display: inline-block;\n- margin: 0.5rem 0;\n- padding: 0.4rem 0.8rem;\n- background: var(--accent);\n- color: #0f1115;\n- border-radius: 4px;\n- font-weight: 600;\n- text-decoration: none;\n-}\n-\n-.parser-rank-link:hover {\n- filter: brightness(1.1);\n+#parser-form .btn-primary {\n+ margin-top: 0.5rem;\n }\n \n .scope-name {\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 5112cb79ffe6e53df3d2432646770fc316cf4787..afeeee24e32f2d7f1d852ca96ac799fac8bce665 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -71,11 +71,11 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n }\n \n #[tokio::test]\n-async fn post_ui_parse_query_morphs_parser_panel() {\n+async fn post_ui_parse_query_redirects_to_subreddit() {\n let (addr, _tmp) = start_test_server().await;\n let rpc = serde_json::json!({\n \"action\": \"parse_query\",\n- \"query\": \"r\"\n+ \"query\": \"r/rust\"\n })\n .to_string();\n let mut form = HashMap::new();\n@@ -92,7 +92,6 @@ async fn post_ui_parse_query_morphs_parser_panel() {\n .await\n .unwrap();\n \n- assert!(body.contains(\"Idiomorph.morph\"));\n- assert!(body.contains(\"parser-panel\"));\n- assert!(body.contains(\"r/\"));\n+ assert!(body.contains(\"window.location.href\"));\n+ assert!(body.contains(\"/?sub=rust\"));\n }\ndiff --git a/test/parser_race.clj b/test/parser_race.clj\ndeleted file mode 100644\nindex 23783837f8192ff24a556b73bdd670dc63b606cb..0000000000000000000000000000000000000000\n--- a/test/parser_race.clj\n+++ /dev/null\n@@ -1,185 +0,0 @@\n-(ns test.parser-race\n- \"Browser test (spel / Playwright) for the parser search-box response race.\n-\n- The search box debounces keystrokes, POSTs each to `/ui`, and eval()s the\n- returned JS, which morphs `#parser-panel` (input value + `#parser-output`).\n- If responses are applied in arrival order with no ordering guard, a slow\n- response for an *earlier* query can land after a newer one and clobber it.\n-\n- To force the race deterministically without touching the Rust server, the\n- browser talks to a small in-process reverse proxy that injects an asymmetric\n- per-query delay: the earlier query (`r/rust`) is delayed far longer than the\n- later query (`r/aww`). The later query therefore renders first, then the\n- stale earlier response arrives. Correct behavior: the panel reflects the\n- *latest* query the user typed (`r/aww`).\"\n- (:require [babashka.process :as process]\n- [clojure.java.io :as io]\n- [clojure.string :as str]\n- [clojure.test :refer [deftest is testing]]\n- [com.blockether.spel.assertions :as assert]\n- [com.blockether.spel.core :as core]\n- [com.blockether.spel.locator :as locator]\n- [com.blockether.spel.page :as page])\n- (:import [com.sun.net.httpserver HttpServer HttpHandler]\n- [java.io ByteArrayOutputStream]\n- [java.net InetSocketAddress URI URLDecoder]\n- [java.net.http HttpClient HttpClient$Version HttpRequest\n- HttpRequest$BodyPublishers HttpResponse$BodyHandlers]\n- [java.nio.charset StandardCharsets]\n- [java.util.concurrent Executors]))\n-\n-(def ^:private slow-query \"r/rust\")\n-(def ^:private fast-query \"r/aww\")\n-(def ^:private slow-delay-ms 800)\n-\n-(defn- repo-root []\n- (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n-\n-(defn- pick-port []\n- (with-open [s (java.net.ServerSocket. 0)]\n- (.getLocalPort s)))\n-\n-(defn- wait-health [base-url ms]\n- (let [deadline (+ (System/currentTimeMillis) ms)\n- url (str base-url \"/healthz\")]\n- (loop []\n- (let [resp (try\n- (process/shell {:out :string :err :string} \"curl\" \"-sf\" url)\n- (catch Exception _ nil))]\n- (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n- true\n- (if (< (System/currentTimeMillis) deadline)\n- (do (Thread/sleep 200) (recur))\n- false))))))\n-\n-(defn- start-server\n- \"Builds the release binary and starts it on a random port. Returns a map with\n- :proc and :base.\"\n- [root]\n- (is (zero? (:exit (process/shell {:dir root}\n- \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n- \"release build succeeds\")\n- (let [bin (str root \"/target/release/sorter2-server\")]\n- (is (.exists (io/file bin)) \"binary exists\")\n- (let [data-dir (.getAbsolutePath\n- (doto (io/file (System/getProperty \"java.io.tmpdir\")\n- (str \"sorter2-race-\" (System/currentTimeMillis)))\n- (.mkdirs)))\n- port (pick-port)\n- base (str \"http://127.0.0.1:\" port)\n- proc (process/process {:dir root\n- :env {\"SORTER2_DATA_DIR\" data-dir\n- \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n- \"PORT\" (str port)}\n- :out :string\n- :err :string}\n- bin)]\n- {:proc proc :base base})))\n-\n-(defn- read-all-bytes ^bytes [in]\n- (let [bos (ByteArrayOutputStream.)]\n- (io/copy in bos)\n- (.toByteArray bos)))\n-\n-(defn- ui-query\n- \"Extracts the decoded `query` form field from a urlencoded body, or nil.\"\n- [body-str]\n- (some-> (re-find #\"(?:^|&)query=([^&]*)\" body-str)\n- second\n- (URLDecoder/decode \"UTF-8\")))\n-\n-(defn- start-proxy\n- \"Reverse proxy to `upstream` that sleeps `(delay-fn method path body-str)` ms\n- before forwarding each request. Runs handlers on a thread pool so concurrent\n- requests are delayed independently (the race needs out-of-order arrival).\n- Returns a map with :server and :base.\"\n- [upstream delay-fn]\n- (let [client (-> (HttpClient/newBuilder)\n- (.version HttpClient$Version/HTTP_1_1)\n- (.build))\n- server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" 0) 0)\n- handler (reify HttpHandler\n- (handle [_ ex]\n- (try\n- (let [method (.getRequestMethod ex)\n- uri (.getRequestURI ex)\n- path (.getRawPath uri)\n- query (.getRawQuery uri)\n- req-body (read-all-bytes (.getRequestBody ex))\n- body-str (String. req-body StandardCharsets/UTF_8)\n- delay-ms (delay-fn method path body-str)]\n- (when (pos? delay-ms)\n- (Thread/sleep (long delay-ms)))\n- (let [target (str upstream path (when query (str \"?\" query)))\n- builder (doto (HttpRequest/newBuilder)\n- (.uri (URI/create target)))\n- ct (.getFirst (.getRequestHeaders ex) \"Content-Type\")\n- _ (when ct (.header builder \"Content-Type\" ct))\n- publisher (if (zero? (alength req-body))\n- (HttpRequest$BodyPublishers/noBody)\n- (HttpRequest$BodyPublishers/ofByteArray req-body))\n- _ (.method builder method publisher)\n- resp (.send client (.build builder)\n- (HttpResponse$BodyHandlers/ofByteArray))\n- resp-body (.body resp)\n- resp-ct (-> (.headers resp)\n- (.firstValue \"content-type\")\n- (.orElse nil))]\n- (when resp-ct\n- (.set (.getResponseHeaders ex) \"Content-Type\" resp-ct))\n- (.sendResponseHeaders ex (.statusCode resp) (alength resp-body))\n- (with-open [os (.getResponseBody ex)]\n- (.write os resp-body))))\n- (catch Throwable t\n- (let [msg (.getBytes (str \"proxy error: \" (.getMessage t))\n- StandardCharsets/UTF_8)]\n- (try\n- (.sendResponseHeaders ex 500 (alength msg))\n- (with-open [os (.getResponseBody ex)]\n- (.write os msg))\n- (catch Throwable _ nil))))\n- (finally\n- (.close ex)))))]\n- (.createContext server \"/\" handler)\n- (.setExecutor server (Executors/newCachedThreadPool))\n- (.start server)\n- {:server server\n- :base (str \"http://127.0.0.1:\" (.getPort (.getAddress server)))}))\n-\n-(defn- parser-delay-fn [method path body-str]\n- (if (and (= method \"POST\") (= path \"/ui\") (= (ui-query body-str) slow-query))\n- slow-delay-ms\n- 0))\n-\n-(deftest latest-search-query-wins\n- (testing \"a slow earlier parser response must not clobber a newer one\"\n- (let [root (repo-root)\n- {:keys [proc base]} (start-server root)]\n- (try\n- (is (wait-health base 15000) \"server responds to /healthz\")\n- (let [{:keys [server] proxy-base :base} (start-proxy base parser-delay-fn)]\n- (try\n- (core/with-testing-page [pg]\n- (page/navigate pg proxy-base)\n- (let [input (page/locator pg \"#parser-input\")\n- output (page/locator pg \"#parser-output\")]\n- ;; Type the slow query first; wait past the 120ms debounce so its\n- ;; (slow) request is in flight, then type the fast query.\n- (locator/fill input slow-query)\n- (Thread/sleep 300)\n- (locator/fill input fast-query)\n- ;; Wait until the slow response has certainly arrived and been\n- ;; (mis)applied if the race exists.\n- (Thread/sleep 2000)\n- ;; The panel must reflect the latest query, not the stale one.\n- (is (nil? (assert/contains-text (assert/assert-that output) fast-query))\n- \"output shows the latest query (r/aww)\")\n- (is (nil? (assert/contains-text\n- (assert/loc-not (assert/assert-that output)) slow-query))\n- \"output does NOT show the stale earlier query (r/rust)\")\n- (is (nil? (assert/has-value (assert/assert-that input) fast-query))\n- \"input value is the latest query (r/aww)\")))\n- (finally\n- (.stop server 0))))\n- (finally\n- (process/destroy proc))))))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[674964ef] refactor: Deref for href newtypes, CanonicalItemUrl through resolve_item\n\n- Implement Deref for GardenItemUrl, ForumThreadUrl, TildeOntologyPath\n- resolve_item returns CanonicalItemUrl; validate uses HashSet\n- compute_scope_rank_changes keys are CanonicalItemUrl; pair RPC uses Vec pool\n- pick_random_distinct_canonical; connectivity stats on &[CanonicalItemUrl]\n- Global rank unranked uses stored ids before GardenItemUrl mapping\n\nMade-with: Cursor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex 03b3e77911ccd662bec8635345dafe2593cf242e..1b291db83df7364a026f2e147e0a29a70a399371 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -30,13 +30,13 @@ pub fn now_ms() -> i64 {\n t.as_millis() as i64\n }\n \n-/// Resolve an item path as a first-class canonical path.\n-pub fn resolve_item(item: &str) -> Result {\n+/// Resolve DSL/user input to a stored canonical item id.\n+pub fn resolve_item(item: &str) -> Result {\n let canonical = canonicalize_item(item);\n if canonical.is_empty() {\n return Err(format!(\"empty item path: `{}`\", item));\n }\n- Ok(canonical)\n+ Ok(CanonicalItemUrl(canonical))\n }\n \n pub fn parse_parent_specs(parent: Option<&String>) -> Vec {\n@@ -94,7 +94,7 @@ pub fn paginate_rankings(\n (out_components, out_unranked)\n }\n \n-pub fn pick_random_distinct(items: &[String]) -> Option<(String, String)> {\n+pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> {\n use rand::seq::SliceRandom;\n if items.len() < 2 {\n return None;\n@@ -123,15 +123,12 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo\n group.voted_pairs.contains(&(i, j))\n }\n \n-pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {\n+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats {\n let n = pool.len();\n \n let global_idxs: Vec> = pool\n .iter()\n- .map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ .map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let present: Vec = global_idxs.iter().filter_map(|x| *x).collect();\n \ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex cf22cb0129366c3aed031bc86f3197a4321cb806..a10ce662105cff8fad949c6b83f7035ce79bed18 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -24,7 +24,7 @@ pub use auth::{\n \n pub use helpers::{\n api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,\n- parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,\n+ parse_parent_specs, pick_random_distinct_canonical, resolve_item, sha256_hex, vote_touches_path,\n };\n \n pub use rpc::handle_rpc_batch;\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 5f7d50188f1381267402f2e57e671234ef5db2fd..de0955d0887d32740e1fd18365205c5bdb53c247 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -29,7 +29,7 @@ use crate::{\n use super::auth::verify_bearer_principal;\n use super::helpers::{\n compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,\n- pick_random_distinct, resolve_item, vote_touches_path,\n+ pick_random_distinct_canonical, resolve_item, vote_touches_path,\n };\n use super::validate::{normalize_room_and_thread, validate_ingest_document};\n \n@@ -146,21 +146,21 @@ fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str)\n }\n \n fn compute_scope_rank_changes(\n- parent: &str,\n+ parent: &CanonicalItemUrl,\n before: &crate::scope_rank::ChildrenRankings,\n after: &crate::scope_rank::ChildrenRankings,\n room_wire: &str,\n ) -> Option {\n- fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n+ fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n let mut map = HashMap::new();\n for comp in &rankings.component_rankings {\n let total = comp.ranked.len();\n for (i, item) in comp.ranked.iter().enumerate() {\n- map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));\n+ map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total }));\n }\n }\n for item in &rankings.unranked_items {\n- map.insert(item.as_str().to_string(), None);\n+ map.insert(item.clone(), None);\n }\n map\n }\n@@ -168,7 +168,7 @@ fn compute_scope_rank_changes(\n let before_pos = build_positions(before);\n let after_pos = build_positions(after);\n \n- let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n+ let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n .chain(after_pos.keys().cloned())\n .collect();\n \n@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(\n };\n if changed {\n changes.push(RankChange {\n- item: GardenItemUrl::from_storage_str(&item, room_wire),\n+ item: GardenItemUrl::from_stored(&item, room_wire),\n before: b,\n after: a,\n });\n@@ -203,11 +203,7 @@ fn compute_scope_rank_changes(\n });\n \n Some(ScopeRankChanges {\n- parent: if parent.is_empty() {\n- \"/\".to_string()\n- } else {\n- GardenItemUrl::from_storage_str(parent, room_wire).into_inner()\n- },\n+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),\n changes,\n })\n }\n@@ -473,8 +469,8 @@ async fn rpc_post(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -525,7 +521,7 @@ async fn rpc_post(\n .filter_map(|p| {\n let before = pre_rankings.get(p)?;\n let after = crate::scope_rank::build_children_rankings(content, p);\n- compute_scope_rank_changes(p.as_str(), before, &after, &room_key)\n+ compute_scope_rank_changes(p, before, &after, &room_key)\n })\n .collect();\n if v.is_empty() { None } else { Some(v) }\n@@ -638,8 +634,8 @@ async fn rpc_check(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -961,7 +957,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&\n async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result {\n let scope = scope_from_room_wire(&room);\n let reduced_arc = state.reduced.clone();\n- let pool: Vec = {\n+ let pool: Vec = {\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n let tmp = if parent_path.trim().is_empty() {\n@@ -970,12 +966,11 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(parent_path.clone())\n };\n let specs = parse_parent_specs(tmp.as_ref());\n- let raw_pool: Vec = if specs.is_empty() {\n+ if specs.is_empty() {\n content.ranking_group.idx_to_item.clone()\n } else {\n crate::scope_rank::resolve_scope(content, &specs)\n- };\n- raw_pool.into_iter().map(|it| it.0).collect()\n+ }\n };\n if pool.len() < 2 {\n return Err((\n@@ -983,31 +978,30 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(\"add items via ingest\".into()),\n ));\n }\n- let selected: Option<(String, String)> = {\n+ let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = {\n let mut reduced = reduced_arc.write().await;\n let content = reduced.content.entry(scope.clone()).or_default();\n let group = &mut content.ranking_group;\n if group.idx_to_item.is_empty() {\n- pick_random_distinct(&pool)\n+ pick_random_distinct_canonical(&pool)\n } else {\n let mut rng = rand::thread_rng();\n- let idxs: Vec = pool.iter()\n- .filter_map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ let idxs: Vec = pool\n+ .iter()\n+ .filter_map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n- let ranked_set: HashSet = ranked.iter().map(|r| r.item.as_str().to_string()).collect();\n- let unsorted: Vec = pool.iter()\n+ let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect();\n+ let unsorted: Vec = pool\n+ .iter()\n .filter(|it| !ranked_set.contains(*it))\n .cloned()\n .collect();\n- let mut pick: Option<(String, String)> = None;\n+ let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None;\n if !unsorted.is_empty() {\n if let Some(left) = unsorted.choose(&mut rng).cloned() {\n- let mut candidates: Vec = if !ranked.is_empty() {\n- ranked.iter().map(|r| r.item.as_str().to_string()).collect()\n+ let mut candidates: Vec = if !ranked.is_empty() {\n+ ranked.iter().map(|r| r.item.clone()).collect()\n } else {\n pool.clone()\n };\n@@ -1021,21 +1015,21 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n let a = ranked[i].item.as_str();\n let b = ranked[i + 1].item.as_str();\n if a != b && !is_pair_voted(group, a, b) {\n- pick = Some((a.to_string(), b.to_string()));\n+ pick = Some((ranked[i].item.clone(), ranked[i + 1].item.clone()));\n break;\n }\n }\n if pick.is_none() {\n for _ in 0..64 {\n let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };\n- if a != b && !is_pair_voted(group, &a, &b) {\n+ if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) {\n pick = Some((a, b));\n break;\n }\n }\n }\n }\n- pick.or_else(|| pick_random_distinct(&pool))\n+ pick.or_else(|| pick_random_distinct_canonical(&pool))\n }\n };\n let Some((left, right)) = selected else {\n@@ -1043,8 +1037,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n };\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n- let left_key = CanonicalItemUrl(left.clone());\n- let right_key = CanonicalItemUrl(right.clone());\n+ let left_key = left.clone();\n+ let right_key = right.clone();\n let lb = content.item_bodies.get(&left_key).cloned();\n let rb = content.item_bodies.get(&right_key).cloned();\n let th: Vec = content\n@@ -1058,8 +1052,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n .collect();\n let cs = compute_connectivity_stats(&content.ranking_group, &pool);\n Ok(RpcResult::Pair(PairResponse {\n- left: GardenItemUrl::from_storage_str(&left, &room),\n- right: GardenItemUrl::from_storage_str(&right, &room),\n+ left: GardenItemUrl::from_stored(&left, &room),\n+ right: GardenItemUrl::from_stored(&right, &room),\n left_body: lb,\n right_body: rb,\n threads: th,\n@@ -1554,7 +1548,7 @@ pub async fn handle_rpc_batch(\n for r in items {\n let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n ranked.push(RankRow {\n- item: GardenItemUrl::from_storage_str(r.item.as_str(), &room),\n+ item: GardenItemUrl::from_stored(&r.item, &room),\n score: r.score,\n percent: pct,\n });\n@@ -1562,11 +1556,11 @@ pub async fn handle_rpc_batch(\n }\n \n let ranked_total = ranked.len();\n- let mut unranked: Vec = content\n+ let mut unranked: Vec = content\n .items\n .iter()\n .filter(|it| !group.item_to_idx.contains_key(*it))\n- .map(|it| it.as_str().to_string())\n+ .cloned()\n .collect();\n unranked.sort();\n let unranked_total = unranked.len();\n@@ -1574,7 +1568,7 @@ pub async fn handle_rpc_batch(\n let page: Vec = ranked\n .into_iter()\n .chain(unranked.into_iter().map(|it| RankRow {\n- item: GardenItemUrl::from_storage_str(&it, &room),\n+ item: GardenItemUrl::from_stored(&it, &room),\n score: 0.0,\n percent: want_percent.then_some(0.0),\n }))\n@@ -1722,7 +1716,7 @@ pub async fn handle_rpc_batch(\n .filter(|p| !parents.contains(p.as_str()))\n .map(|p| GardenItemUrl::from_stored(p, &room))\n .collect();\n- paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n+ paths.sort();\n line_ok(RpcResult::Leaves(LeavesResponse { paths }))\n }\n },\ndiff --git a/server/src/api/validate.rs b/server/src/api/validate.rs\nindex a51c783ee9785569b5a44c0b1572471fe00d174b..3c7aa80fd485cf247231fe5c5c7e6fea62f22534 100644\n--- a/server/src/api/validate.rs\n+++ b/server/src/api/validate.rs\n@@ -52,7 +52,7 @@ pub fn validate_ingest_document(\n };\n \n let ts = super::helpers::now_ms();\n- let mut defined_in_doc: HashSet = HashSet::new();\n+ let mut defined_in_doc: HashSet = HashSet::new();\n \n for s in &doc.statements {\n match s {\n@@ -66,14 +66,14 @@ pub fn validate_ingest_document(\n let Some(body_text) = body else {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item missing body: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item missing body: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"items must be declared with bodies, e.g. `~/path/item { ... }`\".to_string()),\n ));\n };\n if body_text.trim().is_empty() {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item body is empty: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item body is empty: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"write at least one sentence inside `{ ... }`\".to_string()),\n ));\n }\n@@ -102,11 +102,8 @@ pub fn validate_ingest_document(\n };\n let missing: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !item_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !item_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing.is_empty() {\n return Err((\n@@ -120,11 +117,8 @@ pub fn validate_ingest_document(\n }\n let missing_body: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !body_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !body_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing_body.is_empty() {\n return Err((\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex ecdd226b1b17d5f11d1b79f99add5759ef918281..26d648d7c8d6e5d53ce5ed7a051b44856fca2fcf 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -130,8 +130,8 @@ pub async fn editor_check(\n for s in &v.doc.statements {\n if let crate::dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 2950a7502255927583fbacdfd2adb700f0b0c221..839684dfa0ecdd6c2572e2d8132ecee4d5c41bf8 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -3,6 +3,7 @@\n \n use std::borrow::Borrow;\n use std::fmt;\n+use std::ops::Deref;\n \n use serde::{Deserialize, Serialize};\n \n@@ -319,6 +320,14 @@ impl fmt::Display for GardenItemUrl {\n }\n }\n \n+impl Deref for GardenItemUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n fn garden_href_string(item: &str, room_wire: &str) -> String {\n let room = room_wire.trim();\n if room.is_empty() || room == \"public\" {\n@@ -386,6 +395,14 @@ impl fmt::Display for ForumThreadUrl {\n }\n }\n \n+impl Deref for ForumThreadUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n /// `~/a/b` style path for list UIs (paths index `path` field).\n #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n #[serde(transparent)]\n@@ -412,6 +429,14 @@ impl fmt::Display for TildeOntologyPath {\n }\n }\n \n+impl Deref for TildeOntologyPath {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -452,6 +477,13 @@ mod tests {\n assert_eq!(c.tilde_segments(), Vec::<&str>::new());\n }\n \n+ #[test]\n+ fn garden_item_url_deref_to_str() {\n+ let g = GardenItemUrl::from_storage_str(\"https://slug.social/~/x\", \"public\");\n+ let s: &str = &*g;\n+ assert_eq!(s, \"https://slug.social/~/x\");\n+ }\n+\n #[test]\n fn garden_public_passthrough_https() {\n let u = \"https://slug.social/~/a/b\";\n","role":"user"}],"model":"openai/gpt-chat-latest"}