{"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[40b975bf] nice\n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -222,6 +222,12 @@ dependencies = [\n \"syn\",\n ]\n \n+[[package]]\n+name = \"dotenvy\"\n+version = \"0.15.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -1238,6 +1244,7 @@ version = \"0.0.1\"\n dependencies = [\n \"axum\",\n \"axum-extra\",\n+ \"dotenvy\",\n \"maud\",\n \"reqwest\",\n \"serde\",\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -17,6 +17,7 @@ tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\n tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n reqwest = { version = \"0.12\", features = [\"json\"] }\n+dotenvy = \"0.15\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -6,7 +6,7 @@ use axum::{\n use std::collections::HashMap;\n \n use crate::{\n- html::{input_panel, js_string_literal, ranking_panel, JsBuilder},\n+ html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n path_types::ItemId,\n reddit::ensure_partial_tree,\n@@ -87,6 +87,20 @@ pub async fn post_ui_html(\n .into_response()\n }\n },\n+ HtmlUiAction::FetchEntity { item } => {\n+ let id = parse_item_param(&item);\n+ if id.is_root() {\n+ return ui_js_warn(\"nothing to fetch for the root\").into_response();\n+ }\n+ state.queue_entity_fetch(id.clone());\n+ let tree = state.tree.read().await;\n+ let empty = crate::reducer::NodeState::default();\n+ let node = tree.get(&id).unwrap_or(&empty);\n+ let panel = entity_section(&id, node, true);\n+ JsBuilder::new()\n+ .morph_selector(\"#entity-section\", panel)\n+ .into_response()\n+ },\n }\n }\n \ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,4 +1,5 @@\n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(tag = \"type\", rename_all = \"snake_case\")]\n@@ -18,4 +19,10 @@ pub enum Event {\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n+ /// Full upstream API payload for a node (domain-specific view derived at replay/render time).\n+ EntityImported {\n+ id: String,\n+ ts: i64,\n+ payload: Value,\n+ },\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -10,6 +10,7 @@ use crate::{\n form_template::template_json_compact,\n path_types::ItemId,\n ranking::{top_bottom, RankedItem},\n+ reddit::is_fetchable,\n reducer::{GroupState, NodeState},\n state::AppState,\n ui_action::UI_RPC_FIELD,\n@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {\n fn entity_panel(node: &NodeState) -> Markup {\n html! {\n @if let Some(data) = &node.data {\n- section id=\"entity-panel\" class=\"demo-panel entity-card\" {\n+ div id=\"entity-panel\" class=\"entity-card\" {\n h2 { (data.title) }\n @if let Some(author) = &data.author {\n p class=\"muted small\" { \"by \" (author) }\n@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {\n }\n }\n \n+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.\n+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {\n+ if !is_fetchable(item) {\n+ return html! {};\n+ }\n+ let label = if fetching {\n+ \"Fetching…\"\n+ } else if has_data {\n+ \"Fetch more\"\n+ } else {\n+ \"Fetch from Reddit\"\n+ };\n+ let rpc = template_json_compact(&serde_json::json!({\n+ \"action\": \"fetch_entity\",\n+ \"item\": item.as_str(),\n+ }))\n+ .expect(\"fetch_entity rpc template\");\n+ html! {\n+ form method=\"post\" action=\"/ui\" id=\"fetch-entity-form\" class=\"fetch-entity-form\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n+ button type=\"submit\" class=\"btn-secondary\" disabled=(fetching) { (label) }\n+ }\n+ }\n+}\n+\n+/// Entity card + explicit fetch control (morphed as `#entity-section`).\n+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {\n+ let has_data = node.data.is_some();\n+ html! {\n+ section id=\"entity-section\" class=\"demo-panel\" {\n+ (entity_panel(node))\n+ (fetch_entity_panel(item, has_data, fetching))\n+ }\n+ }\n+}\n+\n fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n html! {\n @if !items.is_empty() {\n@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n- (entity_panel(node))\n+ (entity_section(&item, node, false))\n (ranking_panel(&item, group))\n };\n layout(\"sorter2\", body, views)\n@@ -272,16 +309,5 @@ pub async fn home(State(state): State, uri: Uri) -> impl IntoResponse\n \n pub async fn browse(State(state): State, uri: Uri) -> impl IntoResponse {\n let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());\n- if item.as_str().starts_with(\"reddit.com\") {\n- let needs_fetch = {\n- let tree = state.tree.read().await;\n- tree.get(&item)\n- .map(|n| n.data.is_none())\n- .unwrap_or(true)\n- };\n- if needs_fetch {\n- state.reddit.request_fetch(item.clone());\n- }\n- }\n item_page(state, uri, item).await\n }\ndiff --git a/server/src/main.rs b/server/src/main.rs\nindex c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644\n--- a/server/src/main.rs\n+++ b/server/src/main.rs\n@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;\n \n #[tokio::main]\n async fn main() -> Result<(), Box> {\n+ if std::env::var(\"SORTER2_SKIP_DOTENV\").is_err() {\n+ let _ = dotenvy::dotenv();\n+ }\n+\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::try_from_default_env()\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};\n \n use reqwest::{header, Client, StatusCode};\n use serde::Deserialize;\n+use serde_json::Value;\n use tokio::sync::{mpsc, RwLock};\n \n use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ html::now_ms,\n path_types::ItemId,\n- reducer::{EntityData, GlobalTree},\n+ reducer::GlobalTree,\n };\n \n /// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.\n@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n \n pub struct RedditCommand {\n pub id: ItemId,\n+ /// User-initiated fetch bypasses the in-memory \"recently fetched\" cache.\n+ pub force: bool,\n }\n \n #[derive(Clone)]\n@@ -33,19 +39,31 @@ struct RedditCredentials {\n client_secret: String,\n }\n \n+#[derive(Clone)]\n+pub struct RedditApiConfig {\n+ pub api_base: String,\n+ pub oauth_base: String,\n+ pub user_agent: String,\n+ creds: Option,\n+}\n+\n struct OAuthToken {\n access_token: String,\n expires_at: Instant,\n }\n \n impl RedditBroker {\n- pub fn spawn(tree: Arc>, user_agent: &str) -> Self {\n+ pub fn spawn(\n+ tree: Arc>,\n+ event_log: Arc,\n+ config: RedditApiConfig,\n+ ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n \n let mut headers = header::HeaderMap::new();\n headers.insert(\n header::USER_AGENT,\n- header::HeaderValue::from_str(user_agent).expect(\"valid user agent\"),\n+ header::HeaderValue::from_str(&config.user_agent).expect(\"valid user agent\"),\n );\n \n let client = Client::builder()\n@@ -54,22 +72,38 @@ impl RedditBroker {\n .build()\n .expect(\"reqwest client\");\n \n- let creds = RedditCredentials::from_env();\n- tokio::spawn(reddit_worker(rx, tree, client, creds));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n \n Self { tx }\n }\n \n- /// Fire-and-forget: queue a fetch; worker updates the tree when done.\n- pub fn request_fetch(&self, id: ItemId) {\n- let _ = self.tx.try_send(RedditCommand { id });\n+ /// Queue a fetch; drops when the channel is full (backpressure).\n+ pub fn request_fetch(&self, id: ItemId, force: bool) {\n+ let _ = self.tx.try_send(RedditCommand { id, force });\n+ }\n+}\n+\n+impl RedditApiConfig {\n+ pub fn from_env() -> Self {\n+ Self {\n+ api_base: reddit_api_base(),\n+ oauth_base: reddit_oauth_base(),\n+ user_agent: default_user_agent(),\n+ creds: RedditCredentials::from_env(),\n+ }\n }\n }\n \n impl RedditCredentials {\n+ /// Reddit's OAuth docs call these \"client id\" and \"client secret\"; the app\n+ /// registration UI often labels them \"app id\" / \"app secret\" — same values.\n fn from_env() -> Option {\n- let client_id = std::env::var(\"REDDIT_CLIENT_ID\").ok()?;\n- let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\").ok()?;\n+ let client_id = std::env::var(\"REDDIT_CLIENT_ID\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_ID\"))\n+ .ok()?;\n+ let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_SECRET\"))\n+ .ok()?;\n if client_id.is_empty() || client_secret.is_empty() {\n return None;\n }\n@@ -80,29 +114,63 @@ impl RedditCredentials {\n }\n }\n \n+pub fn reddit_api_base() -> String {\n+ std::env::var(\"REDDIT_API_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n+pub fn reddit_oauth_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n pub fn default_user_agent() -> String {\n std::env::var(\"REDDIT_USER_AGENT\").unwrap_or_else(|_| {\n \"web:sorter2.social:v0.0.1 (by /u/sorter2)\".to_string()\n })\n }\n \n+/// True when this node can be loaded from the Reddit JSON API.\n+pub fn is_fetchable(id: &ItemId) -> bool {\n+ !map_item_to_reddit_api(id, \"https://example.com\").is_empty()\n+}\n+\n+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).\n+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option {\n+ if id.as_str().starts_with(\"reddit.com\") {\n+ return parse_reddit_view(id, payload);\n+ }\n+ None\n+}\n+\n+/// Apply a full API payload to the in-memory tree (view derived for known domains).\n+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {\n+ let view = entity_view_from_payload(id, &payload);\n+ tree.apply_entity_raw(id, payload, view);\n+}\n+\n async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n+ event_log: Arc,\n client: Client,\n- creds: Option,\n+ config: RedditApiConfig,\n ) {\n let mut in_flight = HashSet::new();\n let mut recently_fetched: HashMap = HashMap::new();\n let mut current_delay = Duration::from_secs(1);\n let mut oauth: Option = None;\n let cache_ttl = Duration::from_secs(300);\n+ let creds = config.creds.clone();\n+ let api_base = config.api_base.clone();\n+ let oauth_base = config.oauth_base.clone();\n \n while let Some(cmd) = rx.recv().await {\n let now = Instant::now();\n recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);\n \n- if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {\n+ if in_flight.contains(&cmd.id) {\n+ continue;\n+ }\n+ if !cmd.force && recently_fetched.contains_key(&cmd.id) {\n continue;\n }\n \n@@ -112,18 +180,26 @@ async fn reddit_worker(\n tokio::time::sleep(current_delay).await;\n \n if let Some(c) = &creds {\n- oauth = ensure_oauth_token(&client, c, oauth.take()).await;\n+ oauth = ensure_oauth_token(&client, &oauth_base, c, oauth.take()).await;\n }\n \n let token = oauth.as_ref().map(|t| t.access_token.as_str());\n- let use_oauth = token.is_some();\n \n- match do_fetch(&client, &fetch_id, use_oauth, token).await {\n- Ok(FetchOutcome::Entity(data)) => {\n- let mut w = tree.write().await;\n- w.set_entity_data(&fetch_id, data);\n- recently_fetched.insert(fetch_id.clone(), Instant::now());\n- current_delay = Duration::from_millis(600);\n+ match do_fetch(&client, &api_base, &fetch_id, token).await {\n+ Ok(FetchOutcome::Payload(payload)) => {\n+ let ts = now_ms();\n+ let event = Event::EntityImported {\n+ id: fetch_id.as_str().to_string(),\n+ ts,\n+ payload: payload.clone(),\n+ };\n+ if let Err(e) = event_log.append(&event).await {\n+ tracing::warn!(\"event log append failed for {}: {}\", fetch_id, e);\n+ } else {\n+ apply_entity_import(&mut *tree.write().await, &fetch_id, payload);\n+ recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ current_delay = Duration::from_millis(600);\n+ }\n }\n Ok(FetchOutcome::NotFound) => {\n recently_fetched.insert(fetch_id.clone(), Instant::now());\n@@ -149,13 +225,14 @@ async fn reddit_worker(\n }\n \n enum FetchOutcome {\n- Entity(EntityData),\n+ Payload(Value),\n NotFound,\n RateLimited { reset_secs: u64 },\n }\n \n async fn ensure_oauth_token(\n client: &Client,\n+ oauth_base: &str,\n creds: &RedditCredentials,\n existing: Option,\n ) -> Option {\n@@ -165,8 +242,13 @@ async fn ensure_oauth_token(\n }\n }\n \n+ let url = format!(\n+ \"{}/api/v1/access_token\",\n+ oauth_base.trim_end_matches('/')\n+ );\n+\n let resp = client\n- .post(\"https://www.reddit.com/api/v1/access_token\")\n+ .post(&url)\n .basic_auth(&creds.client_id, Some(&creds.client_secret))\n .form(&[(\"grant_type\", \"client_credentials\")])\n .send()\n@@ -207,11 +289,11 @@ async fn ensure_oauth_token(\n \n async fn do_fetch(\n client: &Client,\n+ api_base: &str,\n id: &ItemId,\n- use_oauth: bool,\n bearer: Option<&str>,\n ) -> Result {\n- let url = map_item_to_reddit_api(id, use_oauth);\n+ let url = map_item_to_reddit_api(id, api_base);\n if url.is_empty() {\n return Ok(FetchOutcome::NotFound);\n }\n@@ -241,10 +323,8 @@ async fn do_fetch(\n return Ok(FetchOutcome::RateLimited { reset_secs: reset });\n }\n \n- let bytes = resp.bytes().await.map_err(|e| e.to_string())?;\n- Ok(parse_reddit_json(id, &bytes)\n- .map(FetchOutcome::Entity)\n- .unwrap_or(FetchOutcome::NotFound))\n+ let payload: Value = resp.json().await.map_err(|e| e.to_string())?;\n+ Ok(FetchOutcome::Payload(payload))\n }\n \n fn rate_limit_remaining(resp: &reqwest::Response) -> Option {\n@@ -264,18 +344,14 @@ fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {\n .unwrap_or(5)\n }\n \n-/// Map canonical item id to Reddit JSON API URL.\n-pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n+/// Map canonical item id to a Reddit JSON API URL under `api_base`.\n+pub fn map_item_to_reddit_api(id: &ItemId, api_base: &str) -> String {\n let path = id.as_str();\n if !path.starts_with(\"reddit.com/\") && path != \"reddit.com\" {\n return String::new();\n }\n \n- let base = if oauth {\n- \"https://oauth.reddit.com\"\n- } else {\n- \"https://www.reddit.com\"\n- };\n+ let base = api_base.trim_end_matches('/');\n \n let segments: Vec<&str> = path.split('/').collect();\n \n@@ -293,18 +369,17 @@ pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n String::new()\n }\n \n-fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option {\n- let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;\n+fn parse_reddit_view(id: &ItemId, v: &Value) -> Option {\n let segments: Vec<&str> = id.as_str().split('/').collect();\n \n if segments.iter().any(|&p| p == \"comments\") {\n- parse_post_listing(&v)\n+ parse_post_listing(v)\n } else {\n- parse_subreddit_about(&v)\n+ parse_subreddit_about(v)\n }\n }\n \n-fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n+fn parse_subreddit_about(v: &Value) -> Option {\n let data = v.get(\"data\")?;\n let title = data\n .get(\"title\")\n@@ -323,7 +398,7 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n .filter(|s| !s.is_empty())\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author: None,\n body_html,\n@@ -331,10 +406,9 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n })\n }\n \n-fn parse_post_listing(v: &serde_json::Value) -> Option {\n+fn parse_post_listing(v: &Value) -> Option {\n let listing = v.as_array()?.first()?;\n- let child = listing\n- .pointer(\"/data/children/0/data\")?;\n+ let child = listing.pointer(\"/data/children/0/data\")?;\n let title = child.get(\"title\")?.as_str()?.to_string();\n let author = child\n .get(\"author\")\n@@ -352,7 +426,7 @@ fn parse_post_listing(v: &serde_json::Value) -> Option {\n .filter(|s| s.starts_with(\"http\"))\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author,\n body_html,\n@@ -368,48 +442,51 @@ mod tests {\n fn map_subreddit_about_url() {\n let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/rust/about.json?raw_json=1\"\n );\n assert_eq!(\n- map_item_to_reddit_api(&id, true),\n- \"https://oauth.reddit.com/r/rust/about.json?raw_json=1\"\n+ map_item_to_reddit_api(&id, \"http://127.0.0.1:9999\"),\n+ \"http://127.0.0.1:9999/r/rust/about.json?raw_json=1\"\n );\n }\n \n #[test]\n fn map_post_url() {\n- let id =\n- ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/amitheasshole/comments/1trnvdl.json?raw_json=1\"\n );\n }\n \n #[test]\n- fn map_non_reddit_empty() {\n- let id = ItemId::opaque(\"example.com/foo\");\n- assert!(map_item_to_reddit_api(&id, false).is_empty());\n+ fn is_fetchable_reddit_sub() {\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ assert!(is_fetchable(&id));\n+ assert!(!is_fetchable(&ItemId::opaque(\"example.com/x\")));\n }\n \n #[test]\n fn parse_subreddit_fixture() {\n- let json = r#\"{\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\",\"public_description\":\"systems\"}}\"#;\n- let entity = parse_reddit_json(\n+ let json = include_str!(\"../../test/fixtures/reddit/r_rust_about.json\");\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/rust\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n- assert_eq!(entity.title, \"Rust\");\n+ assert_eq!(entity.title, \"The Rust Programming Language\");\n+ assert!(entity.body_html.as_ref().is_some_and(|b| b.contains(\"Rust\")));\n }\n \n #[test]\n fn parse_post_fixture() {\n let json = r#\"[{\"kind\":\"Listing\",\"data\":{\"children\":[{\"kind\":\"t3\",\"data\":{\"title\":\"AITA\",\"author\":\"op\",\"selftext_html\":\"<p>hi</p>\",\"thumbnail\":\"https://b.thumbs.redditmedia.com/x.jpg\"}}]}}]\"#;\n- let entity = parse_reddit_json(\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/x/comments/abc\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n assert_eq!(entity.title, \"AITA\");\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 077f700bf00ddefe18ffd004bb5288bdc7c4adaf..60db81a562e3590775012573225a76ba82a14563 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,6 +1,7 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -128,6 +129,9 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n+ /// Full imported API JSON (persisted in the event log).\n+ pub entity_raw: Option,\n+ /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -197,10 +201,11 @@ impl GlobalTree {\n }\n }\n \n- pub fn set_entity_data(&mut self, id: &ItemId, data: EntityData) {\n+ pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.data = Some(data);\n+ node.entity_raw = Some(payload);\n+ node.data = view;\n }\n }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8c03aa60c15aee803a534439400b69935b1a3d84..d71d1079a8f486cdab15795384aef0b81b32544d 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -7,7 +7,7 @@ use crate::{\n events::Event,\n journal::JournalClient,\n path_types::ItemId,\n- reddit::{default_user_agent, RedditBroker},\n+ reddit::{apply_entity_import, RedditApiConfig, RedditBroker},\n reducer::{GlobalTree, VoteData},\n views::ViewStore,\n };\n@@ -108,13 +108,18 @@ impl AppState {\n tree.ensure_path(&parsed);\n }\n }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n }\n }\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), &default_user_agent());\n+ let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n \n Self {\n cfg: Arc::new(cfg),\n@@ -135,10 +140,14 @@ impl AppState {\n let mut w = self.tree.write().await;\n w.ensure_path(id);\n }\n- self.reddit.request_fetch(id.clone());\n Ok(())\n }\n \n+ /// User-initiated Reddit/API import (via \"Fetch more\" — never on paste or navigate).\n+ pub fn queue_entity_fetch(&self, id: ItemId) {\n+ self.reddit.request_fetch(id, true);\n+ }\n+\n pub async fn record_vote(\n &self,\n parent: &ItemId,\n@@ -169,6 +178,44 @@ impl AppState {\n #[cfg(test)]\n mod tests {\n use super::{normalize_scope, parse_item_param};\n+ use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ path_types::ItemId,\n+ reddit::apply_entity_import,\n+ reducer::GlobalTree,\n+ };\n+ use serde_json::json;\n+\n+ #[tokio::test]\n+ async fn replay_entity_imported_restores_view() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let log_path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n+ log.append(&Event::EntityImported {\n+ id: \"reddit.com/r/rust\".into(),\n+ ts: 1,\n+ payload: payload.clone(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut tree = GlobalTree::new();\n+ let (events, _) = log.load_all().await.unwrap();\n+ for ev in events {\n+ if let Event::EntityImported { id, payload, .. } = ev {\n+ let parsed = ItemId::parse(&id).unwrap();\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n+ let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n+ assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n+ assert_eq!(\n+ node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n+ \"rust\"\n+ );\n+ }\n \n #[test]\n fn normalize_scope_strips_prefix_and_lowercases() {\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex d798874d1d94c0dfee59ec1ff703f9ee6ef432c0..3d6a49a2a3fb950752827efe1f5a049308f09baf 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -26,6 +26,10 @@ pub enum HtmlUiAction {\n ParseQuery {\n query: String,\n },\n+ /// Fetch upstream entity data for the current page (explicit user action only).\n+ FetchEntity {\n+ item: String,\n+ },\n }\n \n #[derive(Debug, Error)]\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 36f68d415cea33c8562d5d02c0c9d4c5b225a782..1f9f5158414902d43a380dd899232c70d8cf5d63 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -52,6 +52,25 @@ body {\n filter: brightness(1.1);\n }\n \n+.btn-secondary {\n+ background: transparent;\n+ color: var(--accent);\n+ border: 1px solid var(--border);\n+ padding: 0.4rem 0.85rem;\n+ font-size: 0.9rem;\n+ cursor: pointer;\n+ margin-top: 0.75rem;\n+}\n+\n+.btn-secondary:disabled {\n+ opacity: 0.6;\n+ cursor: wait;\n+}\n+\n+.fetch-entity-form {\n+ margin-top: 0.5rem;\n+}\n+\n code {\n font-size: 0.85em;\n background: var(--bg);\ndiff --git a/test/fixtures/reddit/r_rust_about.json b/test/fixtures/reddit/r_rust_about.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb7fd74ab00d63356d9b672a775996ed155b984d\n--- /dev/null\n+++ b/test/fixtures/reddit/r_rust_about.json\n@@ -0,0 +1,21 @@\n+{\n+ \"kind\": \"t5\",\n+ \"data\": {\n+ \"user_flair_background_color\": null,\n+ \"submit_text_html\": null,\n+ \"banner_img\": \"\",\n+ \"user_is_banned\": null,\n+ \"wiki_enabled\": true,\n+ \"show_media\": true,\n+ \"id\": \"2qh0i\",\n+ \"display_name\": \"rust\",\n+ \"title\": \"The Rust Programming Language\",\n+ \"public_description\": \"A place for all things related to the Rust programming language.\",\n+ \"public_description_html\": \"<!-- SC_OFF --><div class=\\\"md\\\"><p>A place for all things related to the Rust programming language.</p>\\n</div><!-- SC_ON -->\",\n+ \"icon_img\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"community_icon\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"subscribers\": 350000,\n+ \"active_user_count\": 1200,\n+ \"over18\": false\n+ }\n+}\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..54edaedeef08718c8f184d6aa468d8e6415068c7\n--- /dev/null\n+++ b/test/reddit_import.clj\n@@ -0,0 +1,116 @@\n+(ns test.reddit-import\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+ (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n+ [java.net InetSocketAddress]))\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- start-mock-reddit [port fixtures-dir]\n+ (let [fixture (io/file fixtures-dir \"r_rust_about.json\")\n+ body (.getBytes (slurp fixture) \"UTF-8\")\n+ server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n+ handler\n+ (proxy [HttpHandler] []\n+ (handle [^HttpExchange exchange]\n+ (.sendResponseHeaders exchange 200 (alength body))\n+ (let [out (.getResponseBody exchange)]\n+ (.write out body)\n+ (.close out))))]\n+ (.createContext server \"/\" handler)\n+ (.setExecutor server nil)\n+ (.start server)\n+ (fn stop []\n+ (.stop server 0))))\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}\n+ \"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- curl-post-ui [base rpc-json]\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" \"-X\" \"POST\" (str base \"/ui\")\n+ \"--data-urlencode\" (str \"__rpc__=\" rpc-json)))\n+\n+(defn- wait-event-log [path ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)]\n+ (loop []\n+ (if (.exists (io/file path))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false)))))\n+\n+(deftest reddit-fetch-via-mock-api\n+ (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n+ (let [root (repo-root)\n+ fixtures (str root \"/test/fixtures/reddit\")\n+ data-dir (.getAbsolutePath\n+ (doto (io/file (System/getProperty \"java.io.tmpdir\")\n+ (str \"sorter2-reddit-\" (System/currentTimeMillis)))\n+ (.mkdirs)))\n+ reddit-port (pick-port)\n+ app-port (pick-port)\n+ reddit-base (str \"http://127.0.0.1:\" reddit-port)\n+ app-base (str \"http://127.0.0.1:\" app-port)\n+ bin (str root \"/target/release/sorter2-server\")\n+ stop-mock (start-mock-reddit reddit-port fixtures)]\n+ (try\n+ (is (zero? (:exit (process/shell {:dir root}\n+ \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n+ \"release build succeeds\")\n+ (let [proc (process/process {:dir root\n+ :env (into (into {} (System/getenv))\n+ {\"SORTER2_SKIP_DOTENV\" \"1\"\n+ \"SORTER2_DATA_DIR\" data-dir\n+ \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n+ \"PORT\" (str app-port)\n+ \"REDDIT_API_BASE\" reddit-base\n+ \"REDDIT_OAUTH_BASE\" reddit-base\n+ \"REDDIT_CLIENT_ID\" \"\"\n+ \"REDDIT_CLIENT_SECRET\" \"\"\n+ \"REDDIT_APP_ID\" \"\"\n+ \"REDDIT_APP_SECRET\" \"\"})\n+ :out :string\n+ :err :string}\n+ bin)]\n+ (try\n+ (is (wait-health app-base 20000) \"app healthz\")\n+ (let [browse-url (str app-base \"/~/https://reddit.com/r/rust\")\n+ before (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))]\n+ (is (str/includes? before \"Fetch from Reddit\"))\n+ (is (not (str/includes? before \"The Rust Programming Language\")))\n+ (let [rpc \"{\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"reddit.com/r/rust\\\"}\"\n+ post (curl-post-ui app-base rpc)\n+ log-path (str data-dir \"/events.jsonl\")]\n+ (is (zero? (:exit post)) \"fetch_entity POST succeeds\")\n+ (is (wait-event-log log-path 10000) \"event log written\")\n+ (let [after (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))\n+ log (slurp (io/file log-path))]\n+ (is (str/includes? after \"The Rust Programming Language\"))\n+ (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n+ (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n+ (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))))\n+ (finally\n+ (process/destroy proc))))\n+ (finally\n+ (stop-mock))))))\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}