{"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[601d3a05] fix(html): drop home toolbar + and ExpandNewThreadForm (single + flow)\n\nPublic home now SSRs #new-thread-ui-slot like room pages: collapsed compose\nfor signed-in users, login hint when logged out. Removes the extra toolbar\nthat morphed the same collapsed state and the expand_new_thread_form action.\n\nMade-with: Cursor\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex e979053ff1ba8c0e2bf55add0a32b7de11cf1e56..f3ce5cb2ab2f923440a8479d0f1fb4acbba166ca 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -139,51 +139,6 @@ async fn dispatch_ui_action(\n }\n }\n }\n- HtmlUiAction::ExpandNewThreadForm { room_wire } => {\n- let room_wire = room_wire.trim().to_string();\n- if room_wire.is_empty() {\n- return ui_js_warn(\"missing room\").into_response();\n- }\n- if room_wire == \"public\" {\n- let reduced = state.reduced.read().await;\n- let user = session.map(|s| s.username.as_str());\n- drop(reduced);\n- let markup = if user.is_some() {\n- fragment_new_thread_slot(&ThreadNav::public(), true, false)\n- } else {\n- login_to_post_hint_markup()\n- };\n- return JsBuilder::new()\n- .morph_inner_selector(\"#new-thread-ui-slot\", markup)\n- .into_response();\n- }\n- let reduced = state.reduced.read().await;\n- let user = session.map(|s| s.username.as_str());\n- if !reduced.rooms.contains(&room_wire) {\n- drop(reduced);\n- return ui_js_warn(\"room not found\").into_response();\n- }\n- if !user_can_view_room(&reduced, &room_wire, user) {\n- drop(reduced);\n- return ui_js_warn(\"forbidden\").into_response();\n- }\n- let can_post = session\n- .as_ref()\n- .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))\n- .unwrap_or(false);\n- drop(reduced);\n- let Some(nav) = ThreadNav::from_room_id(&room_wire) else {\n- return ui_js_warn(\"bad room\").into_response();\n- };\n- let markup = if can_post {\n- fragment_new_thread_slot(&nav, true, false)\n- } else {\n- login_to_post_hint_markup()\n- };\n- JsBuilder::new()\n- .morph_inner_selector(\"#new-thread-ui-slot\", markup)\n- .into_response()\n- }\n HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {\n let room_wire = room_wire.trim().to_string();\n if room_wire.is_empty() {\ndiff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs\nindex 1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d..945bdd6c48bc164e2cf91fd0996c4321b75f5abf 100644\n--- a/server/src/html/forum/feed.rs\n+++ b/server/src/html/forum/feed.rs\n@@ -14,6 +14,7 @@ use crate::timeago;\n \n use super::ingest::ingest_entry_markup;\n use super::nav::ThreadNav;\n+use super::new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};\n use super::page::auth_strip;\n use super::paginator::{render_thread_paginator, PAGE_SIZE};\n use crate::html::{\n@@ -217,9 +218,6 @@ pub async fn home(\n let strip = auth_strip(&headers, &jar, &reduced_read);\n drop(reduced_read);\n \n- use crate::html::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n- use crate::form_template::template_json_compact;\n-\n let page = layout(\n \"slug.social\",\n \"view-thread\",\n@@ -243,15 +241,13 @@ pub async fn home(\n }\n }\n p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n- div class=\"thread-feed-toolbar\" {\n- form method=\"POST\" action=\"/ui\" {\n- input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm {\n- room_wire: \"public\".into(),\n- }).expect(\"static json\"));\n- button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n+ div id=\"new-thread-ui-slot\" {\n+ @if user.is_some() {\n+ (fragment_new_thread_slot(&nav, true, false))\n+ } @else {\n+ (login_to_post_hint_markup())\n }\n }\n- div id=\"new-thread-ui-slot\" {}\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n (cli_panel(&[\"npx slugsocial public forum list\"]))\n },\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 5031ebfeb928f28e471f23210b8c644654adb7c7..da0c9b3541e8e4988a78768cddef22324755c3f2 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -38,11 +38,6 @@ pub enum HtmlUiAction {\n RedactPost {\n post_id: String,\n },\n- /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint).\n- /// Use `room_wire: \"public\"` for the public forum home; otherwise a private room id (`short/slug`).\n- ExpandNewThreadForm {\n- room_wire: String,\n- },\n /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n SetRoomMembersExpanded {\n room_wire: String,\n@@ -131,26 +126,6 @@ mod tests {\n );\n }\n \n- #[test]\n- fn expand_new_thread_form_public() {\n- let template = serde_json::json!({\n- \"action\": \"expand_new_thread_form\",\n- \"room_wire\": \"public\",\n- });\n- let mut form = HashMap::new();\n- form.insert(\n- UI_RPC_FIELD.to_string(),\n- serde_json::to_string(&template).unwrap(),\n- );\n- let a = parse_html_ui_from_form(&form).unwrap();\n- assert_eq!(\n- a,\n- HtmlUiAction::ExpandNewThreadForm {\n- room_wire: \"public\".into(),\n- }\n- );\n- }\n-\n #[test]\n fn expand_post_full_round_trip() {\n let template = serde_json::json!({\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[604a14ad] nice\n\nSide B — unified diff (full patch):\ndiff --git a/.gitignore b/.gitignore\nindex 16de5edb7185b04ef5bc64512814d7dfe2c1f50c..73e8f22cf0d39c706e7cdce5e39f1903a0f9181b 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -4,3 +4,5 @@\n /.lsp/\n *.swp\n .DS_Store\n+data/\n+repomix-output.xml\ndiff --git a/legacy/parser.rs b/legacy/parser.rs\ndeleted file mode 100644\nindex 2b87b974f8d1dd93bee35681d87668a94e4ef349..0000000000000000000000000000000000000000\n--- a/legacy/parser.rs\n+++ /dev/null\n@@ -1,1808 +0,0 @@\n-use std::collections::HashMap;\n-use std::rc::Rc;\n-use std::cell::RefCell;\n-use crate::ui::action::UIAction;\n-use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};\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- }\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\n-type Handler = Box) -> UIAction>;\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\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- let root_node = Rc::new(RefCell::new(Node {\n- id: \"root\",\n- edges: Vec::new(),\n- handler: None,\n- }));\n- nodes.insert(\"root\", root_node);\n- \n- GraphBuilder {\n- nodes,\n- current_node: Some(\"root\"),\n- root: \"root\",\n- }\n- }\n- \n- /// Select a node to add edges to\n- pub fn at(mut self, node_id: NodeId) -> Self {\n- // Create node if it doesn't exist\n- if !self.nodes.contains_key(node_id) {\n- let node = Rc::new(RefCell::new(Node {\n- id: node_id,\n- edges: Vec::new(),\n- handler: None,\n- }));\n- self.nodes.insert(node_id, node);\n- }\n- self.current_node = Some(node_id);\n- self\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(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n- let current = self.current_node.expect(\"No current node selected\");\n- \n- // Create target node if it doesn't exist\n- if !self.nodes.contains_key(target) {\n- let node = Rc::new(RefCell::new(Node {\n- id: target,\n- edges: Vec::new(),\n- handler: None,\n- }));\n- self.nodes.insert(target, node);\n- }\n- \n- // Add edge to current node\n- if let Some(node) = self.nodes.get(current) {\n- node.borrow_mut().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(self, handler: F) -> Self \n- where \n- F: Fn(&str, &str, &HashMap) -> UIAction + 'static\n- {\n- let current = self.current_node.expect(\"No current node selected\");\n- if let Some(node) = self.nodes.get(current) {\n- node.borrow_mut().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-}\n-\n-// --- Parser Implementation ---\n-\n-impl Graph {\n- pub fn parse(&self, input: &str) -> UIAction {\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) -> UIAction {\n- let node = self.nodes.get(state.current_node_id)\n- .expect(\"Node not found in graph\");\n- let node_ref = node.borrow();\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_ref.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_ref, state);\n- }\n- \n- let remaining = &state.input[state.cursor..];\n- \n- // Try to match each edge\n- for edge in &node_ref.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 UIAction::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, UIAction::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_ref, state)\n- }\n- \n- fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {\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 UIAction::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 UIAction::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- UIAction::error(\n- \"InvalidPath\".to_string(),\n- format!(\"'{}' doesn't match any known pattern\", state.original_query)\n- )\n- }\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-}\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- }\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- UIAction::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- UIAction::multiple(vec![\n- UIAction::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- UIAction::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- UIAction::multiple(vec![\n- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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- UIAction::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-/// Parse a query string and return a UI action\n-pub fn parse_reddit_url(query: &str) -> UIAction {\n- let graph = build_reddit_graph();\n- 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: &UIAction) -> bool {\n- match (self, action) {\n- (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {\n- data.suggestion.as_ref()\n- .map(|s| s.completion == *completion)\n- .unwrap_or(false)\n- }\n- (ExpectedAction::ScrollingSuggestions { completions }, UIAction::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 }, UIAction::ShowStaticGuide { title, .. }) => {\n- title.contains(title_contains)\n- }\n- (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n- UIAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderSubredditComments { subreddit }, \n- UIAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.subreddit\" && pk == subreddit\n- }\n- (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {\n- ns == \"reddit.user\" && pk == username\n- }\n- (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n- UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n- s == subreddit && p == prefix\n- }\n- (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {\n- data.error_type == *error_type\n- }\n- (ExpectedAction::Multiple { expected_actions }, UIAction::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, UIAction::ShowMultiple { .. }) => true,\n- (ExpectedAction::DbSuggestions { partial, prefix }, \n- UIAction::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, UIAction)> {\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 UIAction::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 UIAction::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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"r/rust\" should resolve the subreddit\n- text == \"r/rust\" && matches!(action, UIAction::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- UIAction::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- UIAction::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, UIAction::RenderEntityView { ns, pk } \n- if ns == \"reddit.subreddit\" && pk == \"programming\")\n- }),\n- ],\n- );\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, UIAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n- }),\n- ],\n- );\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, UIAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- text == \"r/technology\" && \n- matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n- }),\n- ],\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, UIAction::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, UIAction::ShowMultiple { .. })\n- }),\n- ],\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, UIAction::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, UIAction::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, UIAction::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, UIAction::ShowSuggestions(_))\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/\" should show guide\n- text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n- }),\n- Box::new(|text, action| {\n- // \"https://reddit.com/r/\" should show subreddit selection\n- text == \"https://reddit.com/r/\" && matches!(action, UIAction::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, UIAction::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, UIAction::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, UIAction::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- UIAction::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- }\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- UIAction::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- UIAction::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- UIAction::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- UIAction::ShowMultiple { actions } => {\n- let has_guide = actions.iter()\n- .any(|a| matches!(a, UIAction::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- UIAction::ShowMultiple { actions } => {\n- let has_scrolling_suggestions = actions.iter()\n- .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));\n- let has_db_suggestions = actions.iter()\n- .any(|a| matches!(a, UIAction::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- UIAction::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- UIAction::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- }\n-}\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 2db38f9df9a3ae341597f02948dc0018a055e0b3..c3c62a76f424010d77a6090c84dd0b82098f573e 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -6,7 +6,9 @@ use axum::{\n use std::collections::HashMap;\n \n use crate::{\n- html::{demo_counter_panel, ranking_panel, JsBuilder},\n+ html::{demo_counter_panel, js_string_literal, ranking_panel, JsBuilder},\n+ parser::parse_reddit_url,\n+ parser_render::parser_panel_morph,\n state::AppState,\n ui_action::{parse_html_ui_from_form, HtmlUiAction},\n };\n@@ -58,6 +60,18 @@ 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+ }\n+ js.into_response()\n+ }\n }\n }\n \ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 6bea3d599bd6865a00639a5eb73b19592b059e36..5b1d0b5a887d89e7e80796aa7a6c8ed5baaf2782 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -11,6 +11,8 @@ use serde::Deserialize;\n \n use crate::{\n form_template::template_json_compact,\n+ parser_action::ParserAction,\n+ parser_render::parser_panel,\n ranking::ranked_items,\n reducer::GroupState,\n state::AppState,\n@@ -131,6 +133,13 @@ impl JsBuilder {\n self\n }\n \n+ pub(crate) fn raw(mut self, js: &str) -> Self {\n+ if !js.is_empty() {\n+ self.snippets.push(js.to_string());\n+ }\n+ self\n+ }\n+\n pub(crate) fn build(self) -> String {\n self.snippets.join(\" \")\n }\n@@ -292,7 +301,9 @@ pub async fn home(\n let theme = theme_from_jar(&jar);\n let theme_next = theme_next_from_uri(&uri);\n let mut group = state.group.write().await;\n+ let empty_action = ParserAction::suggest(String::new(), None);\n let body = html! {\n+ (parser_panel(\"\", &empty_action))\n (vote_panel())\n (ranking_panel(&mut group))\n (demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref()))\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 19976e549168e5305c96f490ec31e6d705a54ba2..6716c5b282e7980a7a0f03d63ad8b25eda61cc55 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -3,6 +3,9 @@ pub mod event_log;\n 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;\n pub mod reducer;\ndiff --git a/server/src/parser.rs b/server/src/parser.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4bf201008857e3082b70a55e3dcbb2502ab3ed22\n--- /dev/null\n+++ b/server/src/parser.rs\n@@ -0,0 +1,1807 @@\n+use std::collections::HashMap;\n+use std::rc::Rc;\n+use std::cell::RefCell;\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+ }\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\n+type Handler = Box) -> ParserAction>;\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\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+ let root_node = Rc::new(RefCell::new(Node {\n+ id: \"root\",\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ nodes.insert(\"root\", root_node);\n+ \n+ GraphBuilder {\n+ nodes,\n+ current_node: Some(\"root\"),\n+ root: \"root\",\n+ }\n+ }\n+ \n+ /// Select a node to add edges to\n+ pub fn at(mut self, node_id: NodeId) -> Self {\n+ // Create node if it doesn't exist\n+ if !self.nodes.contains_key(node_id) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: node_id,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(node_id, node);\n+ }\n+ self.current_node = Some(node_id);\n+ self\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(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ \n+ // Create target node if it doesn't exist\n+ if !self.nodes.contains_key(target) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: target,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(target, node);\n+ }\n+ \n+ // Add edge to current node\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().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(self, handler: F) -> Self \n+ where \n+ F: Fn(&str, &str, &HashMap) -> ParserAction + 'static\n+ {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().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+}\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.nodes.get(state.current_node_id)\n+ .expect(\"Node not found in graph\");\n+ let node_ref = node.borrow();\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_ref.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_ref, state);\n+ }\n+ \n+ let remaining = &state.input[state.cursor..];\n+ \n+ // Try to match each edge\n+ for edge in &node_ref.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_ref, 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+}\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+}\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+ }\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+/// Parse a query string and return a UI action\n+pub fn parse_reddit_url(query: &str) -> ParserAction {\n+ let graph = build_reddit_graph();\n+ 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+ }\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+ }\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+ );\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+ );\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+ }\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+ }\n+}\ndiff --git a/server/src/parser_action.rs b/server/src/parser_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2b98556b562d548ce231a726f9e704f595e8f4a1\n--- /dev/null\n+++ b/server/src/parser_action.rs\n@@ -0,0 +1,124 @@\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\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..8f21a9eac5aa200946aca54768f884a7b3a23557\n--- /dev/null\n+++ b/server/src/parser_render.rs\n@@ -0,0 +1,200 @@\n+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+ \"query\": {\"$form\": \"query\"},\n+ }))\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+ 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+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+ (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+ 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+ }\n+ form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n+ input\n+ type=\"text\"\n+ name=\"query\"\n+ id=\"parser-input\"\n+ value=(query)\n+ placeholder=\"r/ or reddit.com/…\"\n+ autocomplete=\"off\"\n+ spellcheck=\"false\";\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(parse_query_rpc_template());\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+ }\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 58ddab733b32f7b624e699c5874bf89e41c5e188..1d488e1ad1d8fd81bd9d016d69255adfe9b22fc8 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -21,6 +21,10 @@ pub enum HtmlUiAction {\n ratio_left: i32,\n ratio_right: i32,\n },\n+ /// Parse address-bar query via Reddit transition graph; morph `#parser-panel`.\n+ ParseQuery {\n+ query: String,\n+ },\n }\n \n #[derive(Debug, Error)]\n@@ -86,4 +90,24 @@ mod tests {\n }\n );\n }\n+\n+ #[test]\n+ fn parse_query_round_trip_with_form_hole() {\n+ let template = serde_json::json!({\n+ \"action\": \"parse_query\",\n+ \"query\": {\"$form\": \"query\"},\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ form.insert(\"query\".into(), \"r/rust\".into());\n+ assert_eq!(\n+ parse_html_ui_from_form(&form).unwrap(),\n+ HtmlUiAction::ParseQuery {\n+ query: \"r/rust\".into(),\n+ }\n+ );\n+ }\n }\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 5cdf32836faf4b06930bcf048cf8cb1b1c1e574e..a00b662e1f8ea75a07521e70a3e74799726878bc 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -8,6 +8,27 @@\n }\n }\n \n+ function postUiForm(form) {\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(evalJs);\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) postUiForm(form);\n+ }, 120);\n+ }\n+\n function initSorterUi() {\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n@@ -16,13 +37,39 @@\n if (f.id === 'sorter-theme-form') return;\n if (f.getAttribute('data-navigate') === 'full') return;\n e.preventDefault();\n- var resp = await fetch(f.action, {\n- method: 'POST',\n- body: new URLSearchParams(new FormData(f)),\n- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n- credentials: 'same-origin',\n- });\n- evalJs(await resp.text());\n+ await postUiForm(f);\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 \ndiff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs\nindex 6eda3499eab29233c42c935f2ae449f5b8be9ab2..8a58bc14aac68c779e7ce4ffa57d27b3220e9953 100644\n--- a/server/tests/integration_health.rs\n+++ b/server/tests/integration_health.rs\n@@ -51,5 +51,6 @@ async fn home_has_demo_panel() {\n .unwrap();\n assert!(html.contains(\"vote-panel\"));\n assert!(html.contains(\"ranking-panel\"));\n+ assert!(html.contains(\"parser-panel\"));\n assert!(html.contains(\"__rpc__\"));\n }\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 3bed92095255b63997db9d5437a1fc40d440bb52..bee79407dec120699a083f1a75adf9291e207fc7 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -95,3 +95,30 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n assert_eq!(ranked.len(), 2);\n assert_eq!(ranked[0].item.as_str(), \"alpha\");\n }\n+\n+#[tokio::test]\n+async fn post_ui_parse_query_morphs_parser_panel() {\n+ let (addr, _tmp) = start_test_server().await;\n+ let rpc = serde_json::json!({\n+ \"action\": \"parse_query\",\n+ \"query\": \"r\"\n+ })\n+ .to_string();\n+ let mut form = HashMap::new();\n+ form.insert(UI_RPC_FIELD.to_string(), rpc);\n+\n+ let client = reqwest::Client::new();\n+ let body = client\n+ .post(format!(\"http://{addr}/ui\"))\n+ .form(&form)\n+ .send()\n+ .await\n+ .unwrap()\n+ .text()\n+ .await\n+ .unwrap();\n+\n+ assert!(body.contains(\"Idiomorph.morph\"));\n+ assert!(body.contains(\"parser-panel\"));\n+ assert!(body.contains(\"r/\"));\n+}\ndiff --git a/test/smoke.clj b/test/smoke.clj\nindex 1a8250b5470f0db27aa2c62664fe92726de2cbd2..b332324743b58baf3fe96d7477353749f137feae 100644\n--- a/test/smoke.clj\n+++ b/test/smoke.clj\n@@ -51,6 +51,7 @@\n \"curl\" \"-sf\" (str base \"/\")))]\n (is (str/includes? home \"vote-panel\"))\n (is (str/includes? home \"ranking-panel\"))\n+ (is (str/includes? home \"parser-panel\"))\n (is (str/includes? home \"__rpc__\")))\n (finally\n (process/destroy proc)))))))\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}