{"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[6e344666] Improve sorterc scan speed, errors, and ingest compile.\n\nMake scan a fast DSL parse pass with human-readable output, surface full parse_error details, and add compile --ingest for single-event replay from a log.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/offline.rs b/server/src/offline.rs\nindex 54ad0ded096a305ef8454ab2cdd1c3af71b14f5d..2db6f67e22e00a24ce673d13095644dd9fa9342d 100644\n--- a/server/src/offline.rs\n+++ b/server/src/offline.rs\n@@ -28,6 +28,10 @@ pub struct CompileResult {\n pub threads: Vec,\n pub rankings: Vec,\n pub stats: CompileStats,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub ingest_id: Option,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub ingest_line: Option,\n }\n \n #[derive(Debug, Clone, Serialize)]\n@@ -36,6 +40,8 @@ pub struct CompileError {\n pub error: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub hint: Option,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub parse_error: Option,\n }\n \n #[derive(Debug, Clone, Serialize)]\n@@ -50,7 +56,7 @@ pub struct MalformedIngest {\n pub id: String,\n pub room_id: String,\n pub thread_tag: String,\n- pub reason: String,\n+ pub parse_error: String,\n }\n \n #[derive(Debug, Clone, Serialize)]\n@@ -59,9 +65,36 @@ pub struct ScanResult {\n pub path: String,\n pub total_lines: usize,\n pub parsed_events: usize,\n+ pub ingest_events: usize,\n pub bad_json_lines: Vec,\n pub malformed_ingests: Vec,\n- pub skipped_ingests: usize,\n+}\n+\n+#[derive(Debug)]\n+pub enum CompileIngestError {\n+ NotFound(String),\n+ Io(std::io::Error),\n+ Compile(CompileError),\n+}\n+\n+impl CompileIngestError {\n+ pub fn into_compile_error(self) -> CompileError {\n+ match self {\n+ Self::NotFound(id) => CompileError {\n+ ok: false,\n+ error: format!(\"ingest not found: {id}\"),\n+ hint: Some(\"pass the ingest event id from events.jsonl\".into()),\n+ parse_error: None,\n+ },\n+ Self::Io(e) => CompileError {\n+ ok: false,\n+ error: format!(\"io error: {e}\"),\n+ hint: None,\n+ parse_error: None,\n+ },\n+ Self::Compile(e) => e,\n+ }\n+ }\n }\n \n fn document_stats(doc: &dsl::Document) -> CompileStats {\n@@ -166,19 +199,22 @@ fn rankings_for_simulated(\n .collect()\n }\n \n-/// Validate and simulate one `.sorter` document against optional base reducer state.\n-pub fn compile_document(\n+fn compile_document_inner(\n base: &ReducerState,\n room: &str,\n text: &str,\n+ ingest_id: Option,\n+ ingest_line: Option,\n ) -> Result {\n let room_key = room.trim();\n let scope = scope_from_room_wire(room_key);\n let validated = validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {\n+ let parse_error = dsl::parse_full(text).err().map(|e| e.to_string());\n CompileError {\n ok: false,\n error: message,\n hint,\n+ parse_error,\n }\n })?;\n \n@@ -200,15 +236,27 @@ pub fn compile_document(\n threads: threads_in_document(text),\n rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),\n stats: document_stats(&validated.doc),\n+ ingest_id,\n+ ingest_line,\n })\n }\n \n+/// Validate and simulate one `.sorter` document against optional base reducer state.\n+pub fn compile_document(\n+ base: &ReducerState,\n+ room: &str,\n+ text: &str,\n+) -> Result {\n+ compile_document_inner(base, room, text, None, None)\n+}\n+\n fn ingest_parse_error(raw: &str) -> Option {\n dsl::parse_full(raw).err().map(|e| e.to_string())\n }\n \n-fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec), std::io::Error> {\n+fn load_events_from_jsonl(path: &Path) -> Result<(usize, Vec<(usize, Event)>, Vec), std::io::Error> {\n let text = std::fs::read_to_string(path)?;\n+ let total_lines = text.lines().count();\n let mut events = Vec::new();\n let mut bad_json_lines = Vec::new();\n for (idx, line) in text.lines().enumerate() {\n@@ -225,61 +273,90 @@ fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec Result<(ReducerState, Vec), std::io::Error> {\n- let (events, bad_json_lines) = load_events_from_jsonl(path)?;\n+fn replay_events(events: &[(usize, Event)]) -> ReducerState {\n let mut state = ReducerState::default();\n for (_line_no, ev) in events {\n- state.apply_event(ev);\n+ state.apply_event(ev.clone());\n }\n- Ok((state, bad_json_lines))\n+ state\n }\n \n-/// Scan an events.jsonl for corrupt JSON lines and ingests that fail DSL replay.\n-pub fn scan_jsonl(path: &Path) -> Result {\n- let text = std::fs::read_to_string(path)?;\n- let total_lines = text.lines().count();\n- let (events, bad_json_lines) = load_events_from_jsonl(path)?;\n+/// Replay a JSONL event log into reducer state (same rules as server boot).\n+pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec), std::io::Error> {\n+ let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;\n+ Ok((replay_events(&events), bad_json_lines))\n+}\n \n- let mut malformed_ingests = Vec::new();\n- let mut skipped_ingests = 0usize;\n- let mut state = ReducerState::default();\n- let parsed_events = events.len();\n+/// Find one ingest in a log and compile it against all prior events as base state.\n+pub fn compile_ingest_from_log(path: &Path, ingest_id: &str) -> Result {\n+ let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path).map_err(CompileIngestError::Io)?;\n+ if !bad_json_lines.is_empty() {\n+ return Err(CompileIngestError::Compile(CompileError {\n+ ok: false,\n+ error: format!(\"jsonl has {} corrupt line(s)\", bad_json_lines.len()),\n+ hint: Some(\"fix the log or use `sorterc scan`\".into()),\n+ parse_error: None,\n+ }));\n+ }\n+\n+ let needle = ingest_id.trim();\n+ let mut found: Option<(usize, Ingest)> = None;\n+ let mut prior: Vec<(usize, Event)> = Vec::new();\n \n for (line_no, ev) in events {\n if let Event::Ingest(ref ing) = ev {\n- if let Some(reason) = ingest_parse_error(&ing.raw) {\n+ if ing.id == needle {\n+ found = Some((line_no, ing.clone()));\n+ break;\n+ }\n+ }\n+ prior.push((line_no, ev));\n+ }\n+\n+ let (line_no, ing) = found.ok_or_else(|| CompileIngestError::NotFound(needle.to_string()))?;\n+ let base = replay_events(&prior);\n+ compile_document_inner(&base, &ing.room_id, &ing.raw, Some(ing.id.clone()), Some(line_no))\n+ .map_err(CompileIngestError::Compile)\n+}\n+\n+/// Scan an events.jsonl for corrupt JSON lines and ingests whose DSL fails to parse.\n+///\n+/// This does not replay the log (which would run rank centrality on every ingest and\n+/// can take minutes on real logs). It matches what the server skips on boot: parse failure.\n+pub fn scan_jsonl(path: &Path) -> Result {\n+ let (total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;\n+\n+ let mut malformed_ingests = Vec::new();\n+ let mut ingest_events = 0usize;\n+\n+ for (line_no, ev) in &events {\n+ if let Event::Ingest(ing) = ev {\n+ ingest_events += 1;\n+ if let Some(parse_error) = ingest_parse_error(&ing.raw) {\n malformed_ingests.push(MalformedIngest {\n- line: line_no,\n+ line: *line_no,\n id: ing.id.clone(),\n room_id: ing.room_id.clone(),\n thread_tag: ing.thread_tag.clone(),\n- reason,\n+ parse_error,\n });\n }\n- let before = state.ingests_by_id.len();\n- state.apply_event(ev);\n- if state.ingests_by_id.len() == before {\n- skipped_ingests += 1;\n- }\n- } else {\n- state.apply_event(ev);\n }\n }\n \n- let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty() && skipped_ingests == 0;\n+ let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty();\n \n Ok(ScanResult {\n ok,\n path: path.display().to_string(),\n total_lines,\n- parsed_events,\n+ parsed_events: events.len(),\n+ ingest_events,\n bad_json_lines,\n malformed_ingests,\n- skipped_ingests,\n })\n }\n \n@@ -330,4 +407,25 @@ mod tests {\n assert!(!report.ok);\n assert_eq!(report.bad_json_lines.len(), 1);\n }\n+\n+ #[test]\n+ fn scan_reports_dsl_parse_error_detail() {\n+ let dir = tempfile::tempdir().unwrap();\n+ let path = dir.path().join(\"events.jsonl\");\n+ let ingest = serde_json::json!({\n+ \"type\": \"ingest\",\n+ \"ts\": 1,\n+ \"id\": \"bad-ingest-id\",\n+ \"raw\": \"{ no closing brace\\n~/a { body }\\n~/a 1:0 ~/b\",\n+ \"principal\": \"test\",\n+ \"room_id\": \"public\",\n+ \"thread_tag\": \"t\",\n+ });\n+ std::fs::write(&path, format!(\"{ingest}\\n\")).unwrap();\n+ let report = scan_jsonl(&path).unwrap();\n+ assert!(!report.ok);\n+ assert_eq!(report.malformed_ingests.len(), 1);\n+ assert_eq!(report.malformed_ingests[0].id, \"bad-ingest-id\");\n+ assert!(report.malformed_ingests[0].parse_error.contains(\"parse error\"));\n+ }\n }\ndiff --git a/sorterc/readme.md b/sorterc/readme.md\nindex 1ebcc3fc935541ea9e47e0458ec67a750fe19fff..e388615c34b40914ca51fc7a79cb738eebc2909b 100644\n--- a/sorterc/readme.md\n+++ b/sorterc/readme.md\n@@ -60,21 +60,33 @@ Rankings use the same structure as the server's dry-run check: parent scope, con\n \n ### `scan` — lint an `events.jsonl`\n \n-Reads a JSONL event log and reports problems without starting a server.\n+Fast single-pass check. Does **not** replay the log (replay runs rank centrality on every ingest and gets slow fast).\n \n ```bash\n cargo run -p sorterc -- scan events.jsonl\n-cargo run -p sorterc -- scan events.jsonl --pretty\n+cargo run -p sorterc -- scan events.jsonl --json\n+cargo run -p sorterc -- scan events.jsonl --json --pretty\n ```\n \n+Default output is human-readable with a blank line between each problem. Use `--json` for machine output.\n+\n Reports:\n \n - **bad JSON lines** — lines that are not valid JSON\n-- **malformed ingests** — ingest events whose `raw` DSL fails to parse\n-- **skipped ingests** — ingests dropped during replay (same behavior as server boot)\n+- **malformed ingests** — ingest events whose `raw` DSL fails to parse, with full `parse_error` text\n \n Exits 0 when clean, 1 when any issue is found.\n \n+### `compile --ingest` — compile one event from a log\n+\n+Replay all events **before** the target ingest as base state, then compile that ingest's DSL:\n+\n+```bash\n+cargo run -p sorterc -- compile --ingest cabd8adc-57ae-402d-a940-8e24339ac451 --from events.jsonl --pretty\n+```\n+\n+Output includes `ingest_id`, `ingest_line`, and rankings for that post only. This may take a while for ingests late in a large log (full replay up to that point).\n+\n ## Typical uses\n \n - Iterate on `.sorter` files in an editor and pipe through `compile` to see rankings instantly\ndiff --git a/sorterc/src/main.rs b/sorterc/src/main.rs\nindex 71382c180085cb0ad71043c852f8db5d3a48a284..5687c850d16973d28749380b8dc89f3bcebbfc56 100644\n--- a/sorterc/src/main.rs\n+++ b/sorterc/src/main.rs\n@@ -22,9 +22,15 @@ struct Cli {\n enum Command {\n /// Parse and simulate a .sorter document; emit ranking JSON to stdout.\n Compile {\n- /// `.sorter` file, or `-` for stdin.\n- file: PathBuf,\n- /// Room wire id (`public` or private room id).\n+ /// `.sorter` file, or `-` for stdin. Omit when using --ingest.\n+ file: Option,\n+ /// Compile one ingest event from a log (by event id / uuid).\n+ #[arg(long)]\n+ ingest: Option,\n+ /// events.jsonl containing the ingest (required with --ingest).\n+ #[arg(long)]\n+ from: Option,\n+ /// Room wire id (`public` or private room id). Ignored with --ingest.\n #[arg(long, default_value = \"public\")]\n room: String,\n /// Optional events.jsonl to replay before compiling (seed garden state).\n@@ -34,9 +40,13 @@ enum Command {\n #[arg(long)]\n pretty: bool,\n },\n- /// Scan an events.jsonl for corrupt JSON lines and malformed ingests.\n+ /// Scan an events.jsonl for corrupt JSON lines and DSL parse failures.\n Scan {\n file: PathBuf,\n+ /// Emit JSON instead of human-readable output.\n+ #[arg(long)]\n+ json: bool,\n+ /// Pretty-print JSON (requires --json).\n #[arg(long)]\n pretty: bool,\n },\n@@ -58,6 +68,7 @@ fn load_base_state(base: Option<&Path>) -> Result {\n let Some(path) = base else {\n return Ok(ReducerState::default());\n };\n+ eprintln!(\"sorterc: replaying {} for base state (may take a while on large logs)…\", path.display());\n let (state, bad_lines) = offline::load_reducer_from_jsonl(path)\n .with_context(|| format!(\"load base jsonl {}\", path.display()))?;\n if !bad_lines.is_empty() {\n@@ -78,25 +89,101 @@ fn print_json(value: &T, pretty: bool) -> Result<()> {\n Ok(())\n }\n \n-fn run_compile(file: PathBuf, room: String, base: Option, pretty: bool) -> Result<()> {\n- let text = read_input(&file)?;\n- let base_state = load_base_state(base.as_deref())?;\n- match offline::compile_document(&base_state, &room, &text) {\n- Ok(result) => {\n- print_json::(&result, pretty)?;\n- Ok(())\n+fn run_compile(\n+ file: Option,\n+ ingest: Option,\n+ from: Option,\n+ room: String,\n+ base: Option,\n+ pretty: bool,\n+) -> Result<()> {\n+ if ingest.is_some() ^ from.is_some() {\n+ bail!(\"--ingest and --from must be used together\");\n+ }\n+ if ingest.is_some() && (file.is_some() || base.is_some()) {\n+ bail!(\"with --ingest/--from, omit file and --base\");\n+ }\n+ if file.is_none() && ingest.is_none() {\n+ bail!(\"pass a .sorter file or --ingest --from events.jsonl\");\n+ }\n+\n+ if let (Some(ingest_id), Some(log_path)) = (ingest, from) {\n+ eprintln!(\n+ \"sorterc: compiling ingest {ingest_id} from {}…\",\n+ log_path.display()\n+ );\n+ match offline::compile_ingest_from_log(&log_path, &ingest_id) {\n+ Ok(result) => {\n+ print_json::(&result, pretty)?;\n+ Ok(())\n+ }\n+ Err(err) => {\n+ print_json::(&err.into_compile_error(), pretty)?;\n+ std::process::exit(1);\n+ }\n+ }\n+ } else {\n+ let file = file.expect(\"checked above\");\n+ let text = read_input(&file)?;\n+ let base_state = load_base_state(base.as_deref())?;\n+ match offline::compile_document(&base_state, &room, &text) {\n+ Ok(result) => {\n+ print_json::(&result, pretty)?;\n+ Ok(())\n+ }\n+ Err(err) => {\n+ print_json::(&err, pretty)?;\n+ std::process::exit(1);\n+ }\n+ }\n+ }\n+}\n+\n+fn print_scan_human(report: &ScanResult) {\n+ if report.ok {\n+ println!(\n+ \"ok: {} ({} lines, {} events, {} ingests)\",\n+ report.path, report.total_lines, report.parsed_events, report.ingest_events\n+ );\n+ return;\n+ }\n+\n+ let problems = report.bad_json_lines.len() + report.malformed_ingests.len();\n+ println!(\n+ \"{}: {} lines, {} events, {} ingests, {problems} problem(s)\",\n+ report.path, report.total_lines, report.parsed_events, report.ingest_events\n+ );\n+\n+ let mut first = true;\n+ for bad in &report.bad_json_lines {\n+ if !first {\n+ println!();\n }\n- Err(err) => {\n- print_json::(&err, pretty)?;\n- std::process::exit(1);\n+ first = false;\n+ println!(\"line {}: invalid JSON\", bad.line);\n+ println!(\"{}\", bad.message);\n+ }\n+\n+ for bad in &report.malformed_ingests {\n+ if !first {\n+ println!();\n }\n+ first = false;\n+ println!(\n+ \"line {}: ingest {} (#{} in {})\",\n+ bad.line, bad.id, bad.thread_tag, bad.room_id\n+ );\n+ println!(\"{}\", bad.parse_error);\n }\n }\n \n-fn run_scan(file: PathBuf, pretty: bool) -> Result<()> {\n- let report = offline::scan_jsonl(&file)\n- .with_context(|| format!(\"scan {}\", file.display()))?;\n- print_json::(&report, pretty)?;\n+fn run_scan(file: PathBuf, json: bool, pretty: bool) -> Result<()> {\n+ let report = offline::scan_jsonl(&file).with_context(|| format!(\"scan {}\", file.display()))?;\n+ if json {\n+ print_json::(&report, pretty)?;\n+ } else {\n+ print_scan_human(&report);\n+ }\n if !report.ok {\n std::process::exit(1);\n }\n@@ -108,10 +195,12 @@ fn main() -> Result<()> {\n match cli.cmd {\n Command::Compile {\n file,\n+ ingest,\n+ from,\n room,\n base,\n pretty,\n- } => run_compile(file, room, base, pretty),\n- Command::Scan { file, pretty } => run_scan(file, pretty),\n+ } => run_compile(file, ingest, from, room, base, pretty),\n+ Command::Scan { file, json, pretty } => run_scan(file, json, pretty),\n }\n }\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}