You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [c3f01e01] cleanup Side A — unified diff (full patch): diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index f9912115d3c6ac18be66c952c3af445ab0f51cea..5aa6a86326ba3545d261322c010e02fe86ee1c57 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -254,17 +254,7 @@ async fn dispatch_ui_action( }; n }; - let js = vote_compare_post_success_js( - state, - &nav, - &room, - &thread_tag, - &left_id, - &right_id, - &pid, - post_index, - ) - .await; + let js = vote_compare_post_success_js(state, &nav, &left_id, &right_id).await; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8") diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 7d309a075405483971109da2f34160409dc608f7..8a46b1e92d38f2c390f88d48750d95d11388cd73 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -176,34 +176,14 @@ fn vote_edge_history_markup( pub(crate) async fn vote_compare_post_success_js( state: &AppState, nav: &ThreadNav, - room_wire: &str, - thread_tag: &str, left: &ItemId, right: &ItemId, - post_id: &str, - post_idx: Option, ) -> String { - use crate::reducer::scope_from_room_wire; let reduced = state.reduced.read().await; - let scope = scope_from_room_wire(room_wire.trim()); - let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else { - drop(reduced); - return "console.warn('vote compare: new post not found');".to_string(); - }; - let idx = match post_idx { - Some(i) => i, - None => reduced - .try_thread_post_index_chronological(&scope, thread_tag, post_id) - .unwrap_or(0), - }; - let viewer = None::<&str>; - let now = now_ms(); - let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced); let content = content_for_garden_view(&reduced, &nav.scope()); let edge_history = vote_edge_history_markup(content, left, right, nav); drop(reduced); let mut b = JsBuilder::new(); - b = b.morph_inner_selector("#vote-compare-preview", card); b = b.morph_inner_selector("#vote-edge-history-region", edge_history); b.build() } Side B — contributor: tommy-mor Side B — commit message: [604a14ad] nice Side B — unified diff (full patch): diff --git a/.gitignore b/.gitignore index 16de5edb7185b04ef5bc64512814d7dfe2c1f50c..73e8f22cf0d39c706e7cdce5e39f1903a0f9181b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ /.lsp/ *.swp .DS_Store +data/ +repomix-output.xml diff --git a/legacy/parser.rs b/legacy/parser.rs deleted file mode 100644 index 2b87b974f8d1dd93bee35681d87668a94e4ef349..0000000000000000000000000000000000000000 --- a/legacy/parser.rs +++ /dev/null @@ -1,1808 +0,0 @@ -use std::collections::HashMap; -use std::rc::Rc; -use std::cell::RefCell; -use crate::ui::action::UIAction; -use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion}; - -// --- Core Abstractions --- - -/// Unique identifier for nodes in the graph -type NodeId = &'static str; - -/// Pattern matching for edges -#[derive(Debug, Clone)] -pub enum EdgePattern { - /// Matches exact literal string - Literal(&'static str), - - /// Matches any prefix of a string and suggests the full string - /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com" - PrefixOf(&'static str), - - /// Captures a variable segment (e.g., subreddit name, username) - Variable(&'static str), - - /// Matches any string (wildcard) - Any, -} - -impl EdgePattern { - /// Try to match this pattern against input, return (consumed_chars, captured_value) - fn matches(&self, input: &str) -> Option<(usize, Option)> { - match self { - EdgePattern::Literal(lit) => { - if input.starts_with(lit) { - Some((lit.len(), None)) - } else { - None - } - } - EdgePattern::PrefixOf(target) => { - // Check if input is a prefix of target - if target.starts_with(input) && !input.is_empty() { - // It's a valid prefix - Some((input.len(), None)) - } else if input.starts_with(target) { - // Full match - Some((target.len(), None)) - } else { - None - } - } - EdgePattern::Variable(var_name) => { - // Consume until next '/' or end of string - let end = input.find('/').unwrap_or(input.len()); - if end > 0 { - let captured = input[..end].to_string(); - // Validate based on variable type - if is_valid_variable(var_name, &captured) { - Some((end, Some(captured))) - } else { - None - } - } else { - None - } - } - EdgePattern::Any => { - // Match everything until next '/' or end - let end = input.find('/').unwrap_or(input.len()); - if end > 0 { - Some((end, Some(input[..end].to_string()))) - } else { - None - } - } - } - } - - /// Get the completion suggestion for this pattern - fn completion(&self, partial: &str) -> Option { - match self { - EdgePattern::PrefixOf(target) => { - if target.starts_with(partial) && partial != *target { - Some(target.to_string()) - } else { - None - } - } - _ => None, - } - } -} - -/// Edge in the graph -pub struct Edge { - pattern: EdgePattern, - target: NodeId, - /// Optional description for autocomplete - description: Option<&'static str>, -} - -/// Handler function for generating UI actions -type Handler = Box) -> UIAction>; - -/// Node in the graph -pub struct Node { - #[allow(dead_code)] - id: NodeId, - edges: Vec, - handler: Option, -} - -/// The composable parser graph -pub struct Graph { - nodes: HashMap>>, - root: NodeId, -} - -// --- Graph Builder (Fluent API) --- - -pub struct GraphBuilder { - nodes: HashMap>>, - current_node: Option, - root: NodeId, -} - -impl GraphBuilder { - pub fn new() -> Self { - let mut nodes = HashMap::new(); - let root_node = Rc::new(RefCell::new(Node { - id: "root", - edges: Vec::new(), - handler: None, - })); - nodes.insert("root", root_node); - - GraphBuilder { - nodes, - current_node: Some("root"), - root: "root", - } - } - - /// Select a node to add edges to - pub fn at(mut self, node_id: NodeId) -> Self { - // Create node if it doesn't exist - if !self.nodes.contains_key(node_id) { - let node = Rc::new(RefCell::new(Node { - id: node_id, - edges: Vec::new(), - handler: None, - })); - self.nodes.insert(node_id, node); - } - self.current_node = Some(node_id); - self - } - - /// Add an edge from the current node - pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self { - self.edge_with_desc(pattern, target, None) - } - - /// Add an edge with description - pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self { - let current = self.current_node.expect("No current node selected"); - - // Create target node if it doesn't exist - if !self.nodes.contains_key(target) { - let node = Rc::new(RefCell::new(Node { - id: target, - edges: Vec::new(), - handler: None, - })); - self.nodes.insert(target, node); - } - - // Add edge to current node - if let Some(node) = self.nodes.get(current) { - node.borrow_mut().edges.push(Edge { - pattern, - target, - description: desc, - }); - } - - self - } - - /// Set handler for current node - pub fn handler(self, handler: F) -> Self - where - F: Fn(&str, &str, &HashMap) -> UIAction + 'static - { - let current = self.current_node.expect("No current node selected"); - if let Some(node) = self.nodes.get(current) { - node.borrow_mut().handler = Some(Box::new(handler)); - } - self - } - - /// Build the final graph - pub fn build(self) -> Graph { - Graph { - nodes: self.nodes, - root: self.root, - } - } -} - -// --- Parser Implementation --- - -impl Graph { - pub fn parse(&self, input: &str) -> UIAction { - let normalized = input.trim().to_lowercase(); - let mut state = ParserState { - input: &normalized, - cursor: 0, - current_node_id: self.root, - context: HashMap::new(), - original_query: input.to_string(), - current_prefix: String::new(), - }; - - self.parse_recursive(&mut state) - } - - fn parse_recursive(&self, state: &mut ParserState) -> UIAction { - let node = self.nodes.get(state.current_node_id) - .expect("Node not found in graph"); - let node_ref = node.borrow(); - - // If we've consumed all input, check for handler or suggestions - if state.cursor >= state.input.len() { - if let Some(handler) = &node_ref.handler { - return handler(&state.original_query, &state.current_prefix, &state.context); - } - - // No handler, try to suggest based on available edges - return self.suggest_from_edges(&node_ref, state); - } - - let remaining = &state.input[state.cursor..]; - - // Try to match each edge - for edge in &node_ref.edges { - if let Some((consumed, captured)) = edge.pattern.matches(remaining) { - // Save state for potential backtracking - let saved_cursor = state.cursor; - let saved_node = state.current_node_id; - let saved_prefix = state.current_prefix.clone(); - - // Update state - state.cursor += consumed; - state.current_node_id = edge.target; - state.current_prefix.push_str(&remaining[..consumed]); - - // Store captured variable if any - if let Some(value) = captured { - if let EdgePattern::Variable(var_name) = &edge.pattern { - state.context.insert(var_name.to_string(), value); - } - } - - // Check if this is a partial match that needs completion - if state.cursor == state.input.len() { - if let Some(completion_suffix) = edge.pattern.completion(remaining) { - // Use the current_prefix plus the completion suffix - let full_completion = format!("{}{}", - state.current_prefix, - completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix) - ); - return UIAction::suggest( - state.original_query.clone(), - Some(Suggestion { - text: full_completion.clone(), - completion: full_completion, - description: edge.description.map(|d| d.to_string()), - score: 1.0, - }) - ); - } - } - - // Continue parsing from the target node - let result = self.parse_recursive(state); - - // If we got a valid response, return it - if !matches!(result, UIAction::ShowError(_)) { - return result; - } - - // Otherwise, restore state and try next edge - state.cursor = saved_cursor; - state.current_node_id = saved_node; - state.current_prefix = saved_prefix; - } - } - - // No edges matched - try to provide suggestions - self.suggest_from_edges(&node_ref, state) - } - - fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction { - let remaining = &state.input[state.cursor..]; - - // Find edges that could match with more input - for edge in &node.edges { - match &edge.pattern { - EdgePattern::PrefixOf(target) => { - if target.starts_with(remaining) && !remaining.is_empty() { - // Use current_prefix instead of rebuilding from input - let full_completion = format!("{}{}", state.current_prefix, target); - return UIAction::suggest( - state.original_query.clone(), - Some(Suggestion { - text: full_completion.clone(), - completion: full_completion, - description: edge.description.map(|d| d.to_string()), - score: 1.0, - }) - ); - } - } - EdgePattern::Literal(lit) => { - if lit.starts_with(remaining) && !remaining.is_empty() { - let full_completion = format!("{}{}", state.current_prefix, lit); - return UIAction::suggest( - state.original_query.clone(), - Some(Suggestion { - text: full_completion.clone(), - completion: full_completion, - description: edge.description.map(|d| d.to_string()), - score: 1.0, - }) - ); - } - } - _ => {} - } - } - - UIAction::error( - "InvalidPath".to_string(), - format!("'{}' doesn't match any known pattern", state.original_query) - ) - } -} - -struct ParserState<'a> { - input: &'a str, - cursor: usize, - current_node_id: NodeId, - context: HashMap, - original_query: String, - current_prefix: String, -} - -// --- Helper Functions --- - -fn is_valid_variable(var_name: &str, value: &str) -> bool { - match var_name { - "subreddit" => { - !value.is_empty() && - value.len() <= 21 && - value.chars().all(|c| c.is_alphanumeric() || c == '_') - } - "username" => { - !value.is_empty() && - value.len() <= 20 && - value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') - } - "post_id" => { - !value.is_empty() && - value.len() <= 10 && - value.chars().all(|c| c.is_alphanumeric()) - } - _ => true, // Allow any value for unknown variables - } -} - -// --- Define the Reddit Graph --- - -pub fn build_reddit_graph() -> Graph { - GraphBuilder::new() - // === ROOT LEVEL: Direct aliases and domain/protocol patterns === - .at("root") - // Direct aliases to subreddit and user selection - .edge_with_desc( - EdgePattern::PrefixOf("r/"), - "subreddit_selection", - Some("Browse subreddits (e.g., r/programming)") - ) - .edge_with_desc( - EdgePattern::PrefixOf("u/"), - "user_selection", - Some("Browse users (e.g., u/spez)") - ) - - // Reddit shortcuts - one pattern handles ALL prefixes! - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Go to Reddit") - ) - - // Protocol patterns - "h" can suggest "https://" - .edge_with_desc( - EdgePattern::PrefixOf("https://"), - "https_protocol", - Some("HTTPS protocol") - ) - .edge_with_desc( - EdgePattern::PrefixOf("http://"), - "http_protocol", - Some("HTTP protocol") - ) - .edge_with_desc( - EdgePattern::PrefixOf("www."), - "www_prefix", - Some("World Wide Web") - ) - - - - // === HTTPS PROTOCOL: Can go to any domain === - .at("https_protocol") - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Reddit (HTTPS)") - ) - .edge_with_desc( - EdgePattern::PrefixOf("www."), - "https_www", - Some("WWW prefix") - ) - - // === HTTP PROTOCOL: Similar to HTTPS === - .at("http_protocol") - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Reddit (HTTP)") - ) - .edge_with_desc( - EdgePattern::PrefixOf("www."), - "http_www", - Some("WWW prefix") - ) - - // === HTTPS + WWW === - .at("https_www") - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Reddit") - ) - - // === HTTP + WWW === - .at("http_www") - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Reddit") - ) - - // === WWW PREFIX (without protocol) === - .at("www_prefix") - .edge_with_desc( - EdgePattern::PrefixOf("reddit.com"), - "reddit_domain", - Some("Reddit") - ) - - // === REDDIT DOMAIN: Expect "/" === - .at("reddit_domain") - .edge(EdgePattern::Literal("/"), "reddit_root") - .handler(|query, prefix, _ctx| { - // If someone just types "reddit.com" (or with protocol) without slash - // Suggest adding the slash using the current prefix - let completion = format!("{}/", prefix); - - UIAction::suggest( - query.to_string(), - Some(Suggestion { - text: completion.clone(), - completion, - description: Some("Continue to Reddit homepage".to_string()), - score: 1.0, - }) - ) - }) - - // === REDDIT ROOT: The main Reddit navigation === - .at("reddit_root") - .edge(EdgePattern::Literal("r/"), "subreddit_selection") - .edge(EdgePattern::Literal("u/"), "user_selection") - .handler(|query, prefix, _ctx| { - UIAction::multiple(vec![ - UIAction::scrolling_suggestions( - query.to_string(), - vec![ - ScrollingSuggestion { - completion: format!("{}r/", prefix), - }, - ScrollingSuggestion { - completion: format!("{}u/", prefix), - }, - ], - 1400, // 1.4 second interval (slower) - true // loop through - ), - UIAction::guide( - query.to_string(), - "Welcome to Sorter for Reddit".to_string(), - "Where would you like to start?".to_string(), - vec![ - GuideOption { - key: "r".to_string(), - label: "Sort a Subreddit".to_string(), - description: "Find the best posts in a community.".to_string(), - completion: format!("{}r/", prefix) - }, - GuideOption { - key: "u".to_string(), - label: "Sort User Content".to_string(), - description: "Explore and rank a user's posts and comments.".to_string(), - completion: format!("{}u/", prefix) - }, - ] - ), - ]) - }) - - - - // === SUBREDDIT SELECTION: THE UNIFIED NODE === - // This node is now reached from `r/` OR `reddit.com/r/` - .at("subreddit_selection") - .edge(EdgePattern::Variable("subreddit"), "subreddit_page") - .handler(|query, prefix, _ctx| { - UIAction::multiple(vec![ - UIAction::scrolling_suggestions( - query.to_string(), - vec![ - ScrollingSuggestion { - completion: format!("{}programming", prefix), - }, - ScrollingSuggestion { - completion: format!("{}askreddit", prefix), - }, - ScrollingSuggestion { - completion: format!("{}aww", prefix), - }, - ScrollingSuggestion { - completion: format!("{}rust", prefix), - }, - ScrollingSuggestion { - completion: format!("{}webdev", prefix), - }, - ], - 1600, // 1.6 second interval (slower) - true // loop through - ), - // Live DB-backed suggestions for subreddits as the user types - UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() } - ]) - }) - - // === Specific subreddit page === - .at("subreddit_page") - .edge(EdgePattern::Literal("/"), "subreddit_slash") - .handler(|_query, prefix, ctx| { - // Use the new unified subreddit resolution logic - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::ResolveAndDisplaySubreddit { - subreddit, - prefix: prefix.to_string(), - } - }) - - .at("subreddit_slash") - .edge(EdgePattern::Literal("hot"), "subreddit_hot") - .edge(EdgePattern::Literal("top"), "subreddit_top") - .edge(EdgePattern::Literal("new"), "subreddit_new") - .edge(EdgePattern::Literal("comments"), "subreddit_comments") - .handler(|query, prefix, ctx| { - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::guide( - query.to_string(), - format!("What to sort in r/{}?", subreddit), - "Choose a category to begin sorting.".to_string(), - vec![ - GuideOption { - key: "hot".to_string(), - label: "Hot Posts".to_string(), - description: "Import and sort posts currently on the front page.".to_string(), - completion: format!("{}hot", prefix) - }, - GuideOption { - key: "top".to_string(), - label: "Top Posts".to_string(), - description: "Import and sort the highest-rated posts.".to_string(), - completion: format!("{}top", prefix) - }, - GuideOption { - key: "new".to_string(), - label: "New Posts".to_string(), - description: "Import and sort the newest posts.".to_string(), - completion: format!("{}new", prefix) - }, - GuideOption { - key: "comments".to_string(), - label: "All Comments".to_string(), - description: "Find the best comment across all imported threads.".to_string(), - completion: format!("{}comments/", prefix) - }, - ] - ) - }) - - - - .at("user_selection") - .edge(EdgePattern::Variable("username"), "user_profile") - .handler(|query, prefix, _ctx| { - UIAction::guide( - query.to_string(), - "User Profile Sorting".to_string(), - "Enter a Reddit username to sort their content.".to_string(), - vec![ - GuideOption { - key: "popular".to_string(), - label: "Popular Users".to_string(), - description: "Browse well-known Reddit users.".to_string(), - completion: prefix.to_string(), - }, - ] - ) - }) - - // === Subreddit sort types === - .at("subreddit_hot") - .handler(|_query, _prefix, ctx| { - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::RenderEntityView { - ns: "reddit.subreddit".to_string(), - pk: subreddit, - } - }) - - .at("subreddit_top") - .handler(|_query, _prefix, ctx| { - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::RenderEntityView { - ns: "reddit.subreddit".to_string(), - pk: subreddit, - } - }) - - .at("subreddit_new") - .handler(|_query, _prefix, ctx| { - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::RenderEntityView { - ns: "reddit.subreddit".to_string(), - pk: subreddit, - } - }) - - .at("subreddit_comments") - .handler(|_query, _prefix, ctx| { - let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); - UIAction::RenderEntityView { - ns: "reddit.subreddit".to_string(), - pk: subreddit, - } - }) - - // === User profile === - .at("user_profile") - .handler(|_query, _prefix, ctx| { - let username = ctx.get("username").cloned().unwrap_or_default(); - UIAction::RenderEntityView { - ns: "reddit.user".to_string(), - pk: username, - } - }) - - // === Build the graph === - .build() -} - -// --- Public API --- - -/// Parse a query string and return a UI action -pub fn parse_reddit_url(query: &str) -> UIAction { - let graph = build_reddit_graph(); - graph.parse(query) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Represents a keystroke action - #[derive(Debug, Clone, PartialEq)] - enum KeyAction { - Type(String), // Type characters - Tab, // Press tab (accept completion) - - } - - /// Expected state after a keystroke - #[derive(Debug, Clone)] - enum ExpectedAction { - Suggestion { completion: String }, - ScrollingSuggestions { completions: Vec }, - Guide { title_contains: String }, - - RenderSubreddit { subreddit: String, sort: Option }, - RenderSubredditComments { subreddit: String }, - RenderUser { username: String }, - ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution - Error { error_type: String }, - Multiple { expected_actions: Vec }, // Multiple responses with specific expectations - MultipleAny, // Multiple responses (any sub-actions - legacy) - DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions - - } - - impl ExpectedAction { - fn matches(&self, action: &UIAction) -> bool { - match (self, action) { - (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => { - data.suggestion.as_ref() - .map(|s| s.completion == *completion) - .unwrap_or(false) - } - (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => { - let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect(); - *completions == actual_completions - } - (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => { - title.contains(title_contains) - } - (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, - UIAction::RenderEntityView { ns, pk }) => { - ns == "reddit.subreddit" && pk == subreddit - } - (ExpectedAction::RenderSubredditComments { subreddit }, - UIAction::RenderEntityView { ns, pk }) => { - ns == "reddit.subreddit" && pk == subreddit - } - (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => { - ns == "reddit.user" && pk == username - } - (ExpectedAction::ResolveSubreddit { subreddit, prefix }, - UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => { - s == subreddit && p == prefix - } - (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => { - data.error_type == *error_type - } - (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => { - // Check that all expected actions are present - if expected_actions.len() != actions.len() { - return false; - } - expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| { - expected.matches(actual) - }) - } - (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true, - (ExpectedAction::DbSuggestions { partial, prefix }, - UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => { - p == partial && pr == prefix - } - - _ => false, - } - } - } - - /// Test helper to simulate a sequence of keystrokes - fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> { - let graph = build_reddit_graph(); - let mut current_text = String::new(); - let mut results = Vec::new(); - - for action in actions { - match action { - KeyAction::Type(text) => { - current_text.push_str(&text); - let result = graph.parse(¤t_text); - results.push((current_text.clone(), result)); - } - KeyAction::Tab => { - // Tab accepts the current suggestion if there is one - let result = graph.parse(¤t_text); - if let UIAction::ShowSuggestions(ref data) = result { - if let Some(ref suggestion) = data.suggestion { - current_text = suggestion.completion.clone(); - let new_result = graph.parse(¤t_text); - results.push((current_text.clone(), new_result)); - } - } - } - - } - } - - results - } - - /// Test a flow using declarative (KeyAction, ExpectedAction) tuples - fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) { - let graph = build_reddit_graph(); - let mut current_text = String::new(); - - println!("\n=== Flow: {} ===", name); - - for (i, (key_action, expected)) in flow.iter().enumerate() { - // Perform the keystroke - match key_action { - KeyAction::Type(text) => { - current_text.push_str(text); - } - KeyAction::Tab => { - // Tab accepts the current suggestion - let result = graph.parse(¤t_text); - if let UIAction::ShowSuggestions(data) = result { - if let Some(suggestion) = &data.suggestion { - current_text = suggestion.completion.clone(); - } - } - } - } - - // Check the result - let actual_action = graph.parse(¤t_text); - - println!(" Step {}: {:?} -> '{}' -> {:?}", - i + 1, key_action, current_text, actual_action); - - assert!( - expected.matches(&actual_action), - "Flow '{}' failed at step {}\n Expected: {:?}\n Actual: {:?}\n Text: '{}'", - name, i + 1, expected, actual_action, current_text - ); - } - - println!("✓ Flow '{}' passed!", name); - } - - /// Legacy helper for backward compatibility (will be removed) - fn assert_flow( - name: &str, - actions: Vec, - expected_checks: Vec bool>>, - ) { - let results = simulate_keystrokes(actions); - - println!("\n=== Flow: {} ===", name); - for (i, (text, action)) in results.iter().enumerate() { - println!(" Step {}: '{}' -> {:?}", i + 1, text, action); - - if i < expected_checks.len() { - let check = &expected_checks[i]; - assert!( - check(text, action), - "Flow '{}' failed at step {} with text '{}' and action {:?}", - name, i + 1, text, action - ); - } - } - println!("✓ Flow '{}' passed!", name); - } - - #[test] - fn test_https_reddit_tab_flow() { - // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/" - assert_flow( - "HTTPS Reddit with Tab", - vec![ - KeyAction::Type("https://reddit.com".to_string()), - KeyAction::Tab, - ], - vec![ - Box::new(|text, action| { - // After typing "https://reddit.com", should get a suggestion - text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if - data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false) - ) - }), - Box::new(|text, action| { - // After tab, should have "https://reddit.com/" and show guide - text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - ], - ); - } - - #[test] - fn test_quick_subreddit_flow() { - // Test: "r" -> TAB -> "rust" (direct alias flow) - assert_flow( - "Quick Subreddit Access", - vec![ - KeyAction::Type("r".to_string()), - KeyAction::Tab, - KeyAction::Type("rust".to_string()), - ], - vec![ - Box::new(|text, action| { - // "r" should suggest "r/" - text == "r" && matches!(action, UIAction::ShowSuggestions(data) if - data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false) - ) - }), - Box::new(|text, action| { - // After tab, should have "r/" and show subreddit selection - text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - Box::new(|text, action| { - // "r/rust" should resolve the subreddit - text == "r/rust" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust") - }), - ], - ); - } - - #[test] - fn test_progressive_completion_flow() { - // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB - let progressive_actions = vec![ - KeyAction::Type("r".to_string()), - KeyAction::Type("e".to_string()), - KeyAction::Type("d".to_string()), - KeyAction::Type("d".to_string()), - KeyAction::Type("i".to_string()), - KeyAction::Type("t".to_string()), - KeyAction::Tab, - ]; - - let results = simulate_keystrokes(progressive_actions); - - println!("\n=== Progressive Completion Flow ==="); - for (i, (text, action)) in results.iter().enumerate() { - println!(" '{}' -> {:?}", text, action); - - // First step: "r" should suggest "r/" - if i == 0 && text == "r" { - match action { - UIAction::ShowSuggestions(data) => { - assert_eq!( - data.suggestion.as_ref().unwrap().completion, - "r/", - "Should suggest r/ at 'r'" - ); - } - _ => panic!("Expected suggestion at 'r'"), - } - } - // Other steps before tab should suggest "reddit.com" - else if i > 0 && i < results.len() - 1 { - match action { - UIAction::ShowSuggestions(data) => { - assert_eq!( - data.suggestion.as_ref().unwrap().completion, - "reddit.com", - "Should suggest reddit.com at '{}'", text - ); - } - _ => panic!("Expected suggestion at '{}'", text), - } - } - } - - // After tab, should have "reddit.com" - let (final_text, _) = results.last().unwrap(); - assert_eq!(final_text, "reddit.com"); - println!("✓ Progressive completion flow passed!"); - } - - #[test] - fn test_subreddit_sort_flow() { - // Test navigating to a subreddit and choosing a sort option - assert_flow( - "Subreddit Sort Navigation", - vec![ - KeyAction::Type("reddit.com/r/programming/hot".to_string()), - ], - vec![ - Box::new(|text, action| { - text == "reddit.com/r/programming/hot" && - matches!(action, UIAction::RenderEntityView { ns, pk } - if ns == "reddit.subreddit" && pk == "programming") - }), - ], - ); - } - - #[test] - fn test_user_profile_flow() { - // Test navigating to a user profile - assert_flow( - "User Profile Navigation", - vec![ - KeyAction::Type("reddit.com/u/spez".to_string()), - ], - vec![ - Box::new(|text, action| { - text == "reddit.com/u/spez" && - matches!(action, UIAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez") - }), - ], - ); - } - - #[test] - fn test_alias_shortcut_flow() { - // Test using the "r/" shortcut - now correctly goes directly to subreddit - assert_flow( - "Alias Shortcut", - vec![ - KeyAction::Type("r/".to_string()), - KeyAction::Type("technology".to_string()), - ], - vec![ - Box::new(|text, action| { - text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - Box::new(|text, action| { - text == "r/technology" && - matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology") - }), - ], - ); - } - - #[test] - fn test_www_prefix_flow() { - // Test with www prefix - assert_flow( - "WWW Prefix", - vec![ - KeyAction::Type("www.reddit.com".to_string()), - KeyAction::Tab, - ], - vec![ - Box::new(|text, action| { - // Should suggest adding slash - text == "www.reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if - data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false) - ) - }), - Box::new(|text, action| { - // After tab, should show guide - text == "www.reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - ], - ); - } - - #[test] - fn test_invalid_path_handling() { - // Test that invalid paths show errors - assert_flow( - "Invalid Path", - vec![ - KeyAction::Type("reddit.com/invalid/path".to_string()), - ], - vec![ - Box::new(|text, action| { - text == "reddit.com/invalid/path" && - matches!(action, UIAction::ShowError(_)) - }), - ], - ); - } - - #[test] - fn test_complete_user_journey() { - // Test a complete user journey: type partial URL, tab complete, navigate to subreddit - assert_flow( - "Complete User Journey", - vec![ - KeyAction::Type("http".to_string()), - KeyAction::Type("s://r".to_string()), - KeyAction::Tab, - KeyAction::Type("/".to_string()), - KeyAction::Type("r/".to_string()), - KeyAction::Type("programming".to_string()), - KeyAction::Type("/".to_string()), - KeyAction::Type("top".to_string()), - ], - vec![ - Box::new(|text, action| { - // "http" should suggest "https://" - text == "http" && matches!(action, UIAction::ShowSuggestions(data) if - data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false) - ) - }), - Box::new(|text, action| { - // "https://r" should suggest "https://reddit.com" - text == "https://r" && matches!(action, UIAction::ShowSuggestions(data) if - data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false) - ) - }), - Box::new(|text, action| { - // After tab, should have "https://reddit.com" - text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(_)) - }), - Box::new(|text, action| { - // "https://reddit.com/" should show guide - text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - Box::new(|text, action| { - // "https://reddit.com/r/" should show subreddit selection - text == "https://reddit.com/r/" && matches!(action, UIAction::ShowMultiple { .. }) - }), - Box::new(|text, action| { - // "https://reddit.com/r/programming" should resolve subreddit - text == "https://reddit.com/r/programming" && - matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } - if subreddit == "programming" && prefix == "https://reddit.com/r/programming") - }), - Box::new(|text, action| { - // "https://reddit.com/r/programming/" should show sort options - text == "https://reddit.com/r/programming/" && - matches!(action, UIAction::ShowStaticGuide { .. }) - }), - Box::new(|text, action| { - // "https://reddit.com/r/programming/top" should render entity view - text == "https://reddit.com/r/programming/top" && - matches!(action, UIAction::RenderEntityView { ns, pk } - if ns == "reddit.subreddit" && pk == "programming") - }), - ], - ); - } - - #[test] - fn test_multiple_tab_completions() { - // Test multiple tab completions in sequence - let actions = vec![ - KeyAction::Type("h".to_string()), - KeyAction::Tab, // Complete to "https://" - KeyAction::Type("r".to_string()), - KeyAction::Tab, // Complete to "https://reddit.com" - KeyAction::Type("/".to_string()), - ]; - - let results = simulate_keystrokes(actions); - - println!("\n=== Multiple Tab Completions ==="); - for (i, (text, _action)) in results.iter().enumerate() { - println!(" Step {}: '{}'", i + 1, text); - } - - // Verify the final state - assert_eq!(results[1].0, "https://"); // After first tab - assert_eq!(results[3].0, "https://reddit.com"); // After second tab - assert_eq!(results[4].0, "https://reddit.com/"); // After typing / - - println!("✓ Multiple tab completions work correctly!"); - } - - // === CONVENIENCE MACROS FOR CLEANER TESTS === - - macro_rules! flow { - ($(($key:expr, $expected:expr)),* $(,)?) => { - vec![$(($key, $expected)),*] - }; - } - - macro_rules! type_text { - ($text:expr) => { - KeyAction::Type($text.to_string()) - }; - } - - macro_rules! suggests { - ($completion:expr) => { - ExpectedAction::Suggestion { completion: $completion.to_string() } - }; - } - - macro_rules! renders_subreddit { - ($subreddit:expr) => { - ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None } - }; - ($subreddit:expr, $sort:expr) => { - ExpectedAction::RenderSubreddit { - subreddit: $subreddit.to_string(), - sort: Some($sort.to_string()) - } - }; - } - - macro_rules! renders_subreddit_comments { - ($subreddit:expr) => { - ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() } - }; - } - - macro_rules! resolves_subreddit { - ($subreddit:expr, $prefix:expr) => { - ExpectedAction::ResolveSubreddit { - subreddit: $subreddit.to_string(), - prefix: $prefix.to_string() - } - }; - } - - macro_rules! shows_guide { - ($title_contains:expr) => { - ExpectedAction::Guide { title_contains: $title_contains.to_string() } - }; - } - - macro_rules! multiple { - ($($action:expr),* $(,)?) => { - ExpectedAction::Multiple { expected_actions: vec![$($action),*] } - }; - } - - macro_rules! scrolling_suggestions { - ($($completion:expr),* $(,)?) => { - ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] } - }; - } - - macro_rules! db_suggestions { - ($partial:expr, $prefix:expr) => { - ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() } - }; - } - - // === NEW DECLARATIVE TESTS === - - #[test] - fn test_declarative_https_tab_flow() { - test_flow("HTTPS Tab Completion", vec![ - (KeyAction::Type("https://reddit.com".to_string()), - ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), - (KeyAction::Tab, - multiple![ - scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), - shows_guide!("Welcome to Sorter") - ]), - ]); - } - - #[test] - fn test_declarative_quick_subreddit_flow() { - test_flow("Quick Subreddit Flow", vec![ - (KeyAction::Type("r".to_string()), - ExpectedAction::Suggestion { completion: "r/".to_string() }), - (KeyAction::Tab, - ExpectedAction::MultipleAny), // r/ shows subreddit selection - (KeyAction::Type("rust".to_string()), - ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }), - ]); - } - - #[test] - fn test_declarative_complete_journey() { - test_flow("Complete User Journey", vec![ - (KeyAction::Type("http".to_string()), - ExpectedAction::Suggestion { completion: "https://".to_string() }), - (KeyAction::Type("s://r".to_string()), - ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }), - (KeyAction::Tab, - ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), - (KeyAction::Type("/r/programming/top".to_string()), - ExpectedAction::RenderSubreddit { - subreddit: "programming".to_string(), - sort: Some("top".to_string()) - }), - ]); - } - - #[test] - fn test_declarative_user_profile() { - test_flow("User Profile Navigation", vec![ - (KeyAction::Type("reddit.com/u/spez".to_string()), - ExpectedAction::RenderUser { username: "spez".to_string() }), - ]); - } - - #[test] - fn test_declarative_error_handling() { - test_flow("Error Handling", vec![ - (KeyAction::Type("reddit.com/invalid/path".to_string()), - ExpectedAction::Error { error_type: "InvalidPath".to_string() }), - ]); - } - - #[test] - fn test_declarative_progressive_completion() { - test_flow("Progressive Completion", vec![ - (KeyAction::Type("r".to_string()), - ExpectedAction::Suggestion { completion: "r/".to_string() }), - (KeyAction::Type("e".to_string()), - ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), - (KeyAction::Type("d".to_string()), - ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), - (KeyAction::Type("dit".to_string()), - ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), - (KeyAction::Tab, - ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }), - ]); - } - - #[test] - fn test_declarative_subreddit_guide() { - test_flow("Subreddit Guide", vec![ - (KeyAction::Type("reddit.com/r/programming/".to_string()), - ExpectedAction::Guide { title_contains: "What to sort".to_string() }), - ]); - } - - #[test] - fn test_clean_macro_example() { - // This is what the tests can look like with macros! - test_flow("Clean Macro Example", flow![ - (type_text!("r"), suggests!("r/")), - (KeyAction::Tab, ExpectedAction::MultipleAny), - (type_text!("rust/hot"), renders_subreddit!("rust", "hot")), - ]); - } - - #[test] - fn test_unified_subreddit_resolution_flow() { - // This test demonstrates the new unified behavior: - // - r/programming (exact) → tries exact match first - // - r/pro (partial) → tries exact match, then suggestions + TAB completion - - test_flow("Unified Resolution: Exact Match", flow![ - (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")), - ]); - - test_flow("Unified Resolution: Partial Match", flow![ - (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")), - ]); - - // Both go through the same action type, but dispatcher handles them differently: - // - If "programming" exists in DB → shows EntityView immediately - // - If "pro" doesn't exist in DB → shows Multiple with: - // 1. Suggestions (for TAB completion to best match) - // 2. Selection (for clickable options including import) - } - - #[test] - fn test_tab_completion_workflow_unified() { - // This demonstrates the desired TAB completion behavior: - // r/pro + TAB → r/programming (if "programming" is the best DB match) - - // Note: This test shows the PARSER behavior. The actual TAB completion - // happens in the frontend when it receives the Multiple response containing - // both Suggestions (for TAB) and Selection (for click options). - - let graph = build_reddit_graph(); - - // 1. Parser generates unified action for partial input - match graph.parse("r/pro") { - UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { - assert_eq!(subreddit, "pro"); - assert_eq!(prefix, "r/pro"); - println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution"); - } - _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"), - } - - // 2. When dispatcher runs (in real app), it will return Multiple response with: - // - Suggestions: { completion: "r/programming" } (for TAB) - // - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks) - - println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)"); - } - - #[test] - fn test_ultra_clean_user_journey() { - test_flow("Ultra Clean User Journey", flow![ - (type_text!("h"), suggests!("https://")), - (type_text!("ttps://r"), suggests!("https://reddit.com")), - (KeyAction::Tab, suggests!("https://reddit.com/")), - (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }), - ]); - } - - // === TESTS FROM parser.tdsl === - - #[test] - fn test_tdsl_basic_reddit_progression() { - // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps - test_flow("TDSL Basic Reddit Progression", flow![ - (type_text!("r"), suggests!("r/")), - (type_text!("e"), suggests!("reddit.com")), - (type_text!("d"), suggests!("reddit.com")), - (type_text!("d"), suggests!("reddit.com")), - (type_text!("i"), suggests!("reddit.com")), - (type_text!("t"), suggests!("reddit.com")), - (type_text!("."), suggests!("reddit.com")), - (type_text!("c"), suggests!("reddit.com")), - (type_text!("o"), suggests!("reddit.com")), - (KeyAction::Tab, suggests!("reddit.com/")), - ]); - } - - #[test] - fn test_tdsl_reddit_com_slash_infographic() { - // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} - test_flow("TDSL Reddit.com/ Infographic", flow![ - (type_text!("reddit.com/"), ExpectedAction::MultipleAny), - ]); - } - - #[test] - fn test_tdsl_subreddit_selection() { - // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} - test_flow("TDSL Subreddit Selection", flow![ - (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), - ]); - } - - #[test] - fn test_tdsl_subreddit_view() { - // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)} - test_flow("TDSL Subreddit View", flow![ - (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")), - ]); - } - - #[test] - fn test_tdsl_subreddit_slash_infographic() { - // reddit.com/r/{sub}/ -> {show infographic or something} - test_flow("TDSL Subreddit Slash Infographic", flow![ - (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")), - ]); - } - - #[test] - fn test_tdsl_subreddit_comments() { - // reddit.com/r/{sub}/comments/{randomly chose comment from sql} - test_flow("TDSL Subreddit Comments", flow![ - (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")), - ]); - } - - #[test] - fn test_tdsl_h_to_https() { - // h->https:// (show supported domains) - test_flow("TDSL H to HTTPS", flow![ - (type_text!("h"), suggests!("https://")), - ]); - } - - #[test] - fn test_tdsl_composable_https_reddit() { - // https://r->https://reddit.com/ - test_flow("TDSL Composable HTTPS Reddit", flow![ - (type_text!("https://r"), suggests!("https://reddit.com")), - ]); - } - - #[test] - fn test_tdsl_composable_https_www() { - // https://w->https://www. - test_flow("TDSL Composable HTTPS WWW", flow![ - (type_text!("https://w"), suggests!("https://www.")), - ]); - } - - #[test] - fn test_tdsl_composable_https_www_reddit() { - // https://www.r->https://www.reddit.com/ - test_flow("TDSL Composable HTTPS WWW Reddit", flow![ - (type_text!("https://www.r"), suggests!("https://www.reddit.com")), - ]); - } - - #[test] - fn test_tdsl_full_composable_chain() { - // Complete chain showing composability: h -> https:// -> https://www.reddit.com - test_flow("TDSL Full Composable Chain Step 1", flow![ - (type_text!("h"), suggests!("https://")), - ]); - - test_flow("TDSL Full Composable Chain Step 2", flow![ - (type_text!("https://w"), suggests!("https://www.")), - ]); - - test_flow("TDSL Full Composable Chain Step 3", flow![ - (type_text!("https://www.r"), suggests!("https://www.reddit.com")), - ]); - } - - #[test] - fn test_tdsl_progressive_reddit_paths() { - // Test various reddit paths work as expected - test_flow("TDSL Progressive Reddit Paths", flow![ - (type_text!("reddit.com"), suggests!("reddit.com/")), - (KeyAction::Tab, ExpectedAction::MultipleAny), - ]); - } - - #[test] - fn test_tdsl_subreddit_sorting_options() { - // Test that subreddit sorting options work as described - test_flow("TDSL Subreddit Sorting", flow![ - (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")), - ]); - - test_flow("TDSL Subreddit Top", flow![ - (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")), - ]); - - test_flow("TDSL Subreddit New", flow![ - (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")), - ]); - } - - #[test] - fn test_tdsl_all_reddit_prefixes() { - // Test all the prefixes mentioned in parser.tdsl work - // "r" now suggests "r/", all others suggest "reddit.com" - let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; - - // Test "r" separately since it now suggests "r/" - test_flow("TDSL Prefix: r", flow![ - (type_text!("r"), suggests!("r/")), - ]); - - for prefix in prefixes_to_reddit { - test_flow(&format!("TDSL Prefix: {}", prefix), flow![ - (type_text!(prefix), suggests!("reddit.com")), - ]); - } - } - - #[test] - fn test_tdsl_protocol_combinations() { - // Test various protocol combinations from parser.tdsl - let test_cases = vec![ - ("http://r", "http://reddit.com"), - ("https://r", "https://reddit.com"), - ("www.r", "www.reddit.com"), - ("https://www.r", "https://www.reddit.com"), - ("http://www.r", "http://www.reddit.com"), - ]; - - for (input, expected) in test_cases { - test_flow(&format!("TDSL Protocol: {}", input), flow![ - (type_text!(input), suggests!(expected)), - ]); - } - } - - #[test] - fn test_tdsl_edge_case_completions() { - // Test edge cases mentioned in parser.tdsl - test_flow("TDSL Reddit.com completion", flow![ - (type_text!("reddit.com"), suggests!("reddit.com/")), - ]); - - // Test that typing full reddit.com suggests the slash - test_flow("TDSL Full domain completion", flow![ - (type_text!("reddit.com"), suggests!("reddit.com/")), - (KeyAction::Tab, ExpectedAction::MultipleAny), - ]); - } - - #[test] - fn test_real_user_trace_2025_08_07_fixed() { - // Based on actual WebSocket trace from 2025-08-07T01:35:46Z - // This test shows the CORRECT behavior after fixing the protocol preservation bug - test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![ - // User started typing "h" - (type_text!("h"), suggests!("https://")), - - // User continued to "ht" - (type_text!("t"), suggests!("https://")), - - // User finished typing "https://" (trace shows full protocol) - (type_text!("tps://"), suggests!("https://")), - - // User started typing "r" after protocol - (type_text!("r"), suggests!("https://reddit.com")), - - // User continued typing "re" - (type_text!("e"), suggests!("https://reddit.com")), - - // User typed out or completed "https://reddit.com" - (type_text!("ddit.com"), suggests!("https://reddit.com/")), - - // User accepted completion to "https://reddit.com/" - // NOW suggestions should preserve the https:// protocol - (KeyAction::Tab, multiple![ - scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // This is the key fix! - shows_guide!("Welcome to Sorter") - ]), - ]); - } - - #[test] - fn test_protocol_preservation_bug_fix() { - // This test specifically verifies the fix for the protocol preservation bug - test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![ - (type_text!("https://reddit.com/"), multiple![ - scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // Should preserve https:// - shows_guide!("Welcome to Sorter") - ]), - ]); - - // Test that subreddit selection preserves protocol - test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![ - (type_text!("https://reddit.com/r/"), multiple![ - 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:// - db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/") - ]), - ]); - - // Test with different protocols - test_flow("Protocol Preservation: HTTP", flow![ - (type_text!("http://reddit.com/"), multiple![ - scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"), // Should preserve http:// - shows_guide!("Welcome to Sorter") - ]), - ]); - - test_flow("Protocol Preservation: WWW", flow![ - (type_text!("www.reddit.com/"), multiple![ - scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"), // Should preserve www. - shows_guide!("Welcome to Sorter") - ]), - ]); - - // Test that plain reddit.com still works - test_flow("Protocol Preservation: Plain Domain", flow![ - (type_text!("reddit.com/"), multiple![ - scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"), // No protocol prefix - shows_guide!("Welcome to Sorter") - ]), - ]); - } - - #[test] - fn test_user_navigation_pattern() { - // Models how users actually navigate: type → tab → click suggestion → end up at destination - // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace - test_flow("User Navigation: Protocol to Domain", flow![ - // User types and gets to homepage - (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny), - ]); - - // Then they navigate (perhaps clicking a suggestion) to subreddit selection - test_flow("User Navigation: Click to Subreddit", flow![ - // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/" - (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), - ]); - } - - #[test] - fn test_progressive_typing_pattern() { - // Based on the trace pattern - users often type character by character - // This tests the exact sequence of suggestions they would see - test_flow("Progressive Typing Pattern", flow![ - (type_text!("h"), suggests!("https://")), - (type_text!("t"), suggests!("https://")), // Still suggests https:// - (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https:// - (type_text!("p"), suggests!("https://")), // http should still suggest https:// - (type_text!("s"), suggests!("https://")), // https should suggest https:// - (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself - ]); - } - - #[test] - fn test_tab_completion_workflow() { - // Test what happens when user uses tab completions strategically - test_flow("Strategic Tab Completion Workflow", flow![ - // Start typing, get suggestion - (type_text!("h"), suggests!("https://")), - - // Accept suggestion with tab - this should move us to "https://" - (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself - - // Start typing reddit - (type_text!("r"), suggests!("https://reddit.com")), - - // Accept reddit suggestion - (KeyAction::Tab, suggests!("https://reddit.com/")), - - // Accept final suggestion to get to homepage - (KeyAction::Tab, ExpectedAction::MultipleAny), - ]); - } - - // Keep the original tests as well - #[test] - fn test_reddit_prefix_autocomplete() { - let graph = build_reddit_graph(); - - // "r" now suggests "r/", others suggest "reddit.com" - match graph.parse("r") { - UIAction::ShowSuggestions(data) => { - assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/"); - println!("✓ 'r' → r/"); - } - _ => panic!("Expected suggestion for 'r'"), - } - - // Test other prefixes that should suggest "reddit.com" - let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; - - for prefix in prefixes { - match graph.parse(prefix) { - UIAction::ShowSuggestions(data) => { - assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com"); - println!("✓ '{}' → reddit.com", prefix); - } - _ => panic!("Expected suggestion for '{}'", prefix), - } - } - } - - #[test] - fn test_protocol_composition() { - let graph = build_reddit_graph(); - - // Test protocol + reddit compositions - let tests = vec![ - ("https://r", "https://reddit.com"), - ("https://re", "https://reddit.com"), - ("https://reddit", "https://reddit.com"), - ("https://www.r", "https://www.reddit.com"), - ("https://www.reddit", "https://www.reddit.com"), - ("http://r", "http://reddit.com"), - ("www.r", "www.reddit.com"), - ]; - - for (input, expected) in tests { - match graph.parse(input) { - UIAction::ShowSuggestions(data) => { - assert_eq!(data.suggestion.as_ref().unwrap().completion, expected); - println!("✓ '{}' → {}", input, expected); - } - _ => panic!("Expected suggestion for '{}'", input), - } - } - } - - #[test] - fn test_alias_and_full_paths() { - let graph = build_reddit_graph(); - - // reddit.com/ should show guide, r/ should show subreddit selection - match graph.parse("reddit.com/") { - UIAction::ShowMultiple { actions } => { - let has_guide = actions.iter() - .any(|a| matches!(a, UIAction::ShowStaticGuide { .. })); - assert!(has_guide, "Path 'reddit.com/' should show guide"); - println!("✓ 'reddit.com/' shows Reddit root guide"); - } - _ => panic!("Expected Multiple action for 'reddit.com/'"), - } - - match graph.parse("r/") { - UIAction::ShowMultiple { actions } => { - let has_scrolling_suggestions = actions.iter() - .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. })); - let has_db_suggestions = actions.iter() - .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. })); - assert!(has_scrolling_suggestions && has_db_suggestions, - "Path 'r/' should show scrolling suggestions and DB suggestions"); - println!("✓ 'r/' shows subreddit selection"); - } - _ => panic!("Expected Multiple action for 'r/'"), - } - } - - #[test] - fn test_deep_navigation() { - let graph = build_reddit_graph(); - - // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit - match graph.parse("reddit.com/r/rust") { - UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { - assert_eq!(subreddit, "rust"); - assert_eq!(prefix, "reddit.com/r/rust"); - println!("✓ reddit.com/r/rust recognized"); - } - _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"), - } - - // Test with alias (r/ goes directly to subreddit, no double r/) - match graph.parse("r/programming") { - UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { - assert_eq!(subreddit, "programming"); - assert_eq!(prefix, "r/programming"); - println!("✓ r/programming (alias) recognized"); - } - _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"), - } - } -} diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index 2db38f9df9a3ae341597f02948dc0018a055e0b3..c3c62a76f424010d77a6090c84dd0b82098f573e 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -6,7 +6,9 @@ use axum::{ use std::collections::HashMap; use crate::{ - html::{demo_counter_panel, ranking_panel, JsBuilder}, + html::{demo_counter_panel, js_string_literal, ranking_panel, JsBuilder}, + parser::parse_reddit_url, + parser_render::parser_panel_morph, state::AppState, ui_action::{parse_html_ui_from_form, HtmlUiAction}, }; @@ -58,6 +60,18 @@ pub async fn post_ui_html( .morph_selector("#ranking-panel", panel) .into_response() } + HtmlUiAction::ParseQuery { query } => { + let action = parse_reddit_url(&query); + let panel = parser_panel_morph(&query, &action); + let mut js = JsBuilder::new().morph_selector("#parser-panel", panel); + if let Some(comp) = action.primary_completion() { + js = js.raw(&format!( + "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}", + js_string_literal(comp) + )); + } + js.into_response() + } } } diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 6bea3d599bd6865a00639a5eb73b19592b059e36..5b1d0b5a887d89e7e80796aa7a6c8ed5baaf2782 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -11,6 +11,8 @@ use serde::Deserialize; use crate::{ form_template::template_json_compact, + parser_action::ParserAction, + parser_render::parser_panel, ranking::ranked_items, reducer::GroupState, state::AppState, @@ -131,6 +133,13 @@ impl JsBuilder { self } + pub(crate) fn raw(mut self, js: &str) -> Self { + if !js.is_empty() { + self.snippets.push(js.to_string()); + } + self + } + pub(crate) fn build(self) -> String { self.snippets.join(" ") } @@ -292,7 +301,9 @@ pub async fn home( let theme = theme_from_jar(&jar); let theme_next = theme_next_from_uri(&uri); let mut group = state.group.write().await; + let empty_action = ParserAction::suggest(String::new(), None); let body = html! { + (parser_panel("", &empty_action)) (vote_panel()) (ranking_panel(&mut group)) (demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref())) diff --git a/server/src/lib.rs b/server/src/lib.rs index 19976e549168e5305c96f490ec31e6d705a54ba2..6716c5b282e7980a7a0f03d63ad8b25eda61cc55 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,9 @@ pub mod event_log; pub mod events; pub mod form_template; pub mod html; +pub mod parser; +pub mod parser_action; +pub mod parser_render; pub mod path_types; pub mod ranking; pub mod reducer; diff --git a/server/src/parser.rs b/server/src/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..4bf201008857e3082b70a55e3dcbb2502ab3ed22 --- /dev/null +++ b/server/src/parser.rs @@ -0,0 +1,1807 @@ +use std::collections::HashMap; +use std::rc::Rc; +use std::cell::RefCell; +use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion}; + +// --- Core Abstractions --- + +/// Unique identifier for nodes in the graph +type NodeId = &'static str; + +/// Pattern matching for edges +#[derive(Debug, Clone)] +pub enum EdgePattern { + /// Matches exact literal string + Literal(&'static str), + + /// Matches any prefix of a string and suggests the full string + /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com" + PrefixOf(&'static str), + + /// Captures a variable segment (e.g., subreddit name, username) + Variable(&'static str), + + /// Matches any string (wildcard) + Any, +} + +impl EdgePattern { + /// Try to match this pattern against input, return (consumed_chars, captured_value) + fn matches(&self, input: &str) -> Option<(usize, Option)> { + match self { + EdgePattern::Literal(lit) => { + if input.starts_with(lit) { + Some((lit.len(), None)) + } else { + None + } + } + EdgePattern::PrefixOf(target) => { + // Check if input is a prefix of target + if target.starts_with(input) && !input.is_empty() { + // It's a valid prefix + Some((input.len(), None)) + } else if input.starts_with(target) { + // Full match + Some((target.len(), None)) + } else { + None + } + } + EdgePattern::Variable(var_name) => { + // Consume until next '/' or end of string + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + let captured = input[..end].to_string(); + // Validate based on variable type + if is_valid_variable(var_name, &captured) { + Some((end, Some(captured))) + } else { + None + } + } else { + None + } + } + EdgePattern::Any => { + // Match everything until next '/' or end + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + Some((end, Some(input[..end].to_string()))) + } else { + None + } + } + } + } + + /// Get the completion suggestion for this pattern + fn completion(&self, partial: &str) -> Option { + match self { + EdgePattern::PrefixOf(target) => { + if target.starts_with(partial) && partial != *target { + Some(target.to_string()) + } else { + None + } + } + _ => None, + } + } +} + +/// Edge in the graph +pub struct Edge { + pattern: EdgePattern, + target: NodeId, + /// Optional description for autocomplete + description: Option<&'static str>, +} + +/// Handler function for generating UI actions +type Handler = Box) -> ParserAction>; + +/// Node in the graph +pub struct Node { + #[allow(dead_code)] + id: NodeId, + edges: Vec, + handler: Option, +} + +/// The composable parser graph +pub struct Graph { + nodes: HashMap>>, + root: NodeId, +} + +// --- Graph Builder (Fluent API) --- + +pub struct GraphBuilder { + nodes: HashMap>>, + current_node: Option, + root: NodeId, +} + +impl GraphBuilder { + pub fn new() -> Self { + let mut nodes = HashMap::new(); + let root_node = Rc::new(RefCell::new(Node { + id: "root", + edges: Vec::new(), + handler: None, + })); + nodes.insert("root", root_node); + + GraphBuilder { + nodes, + current_node: Some("root"), + root: "root", + } + } + + /// Select a node to add edges to + pub fn at(mut self, node_id: NodeId) -> Self { + // Create node if it doesn't exist + if !self.nodes.contains_key(node_id) { + let node = Rc::new(RefCell::new(Node { + id: node_id, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(node_id, node); + } + self.current_node = Some(node_id); + self + } + + /// Add an edge from the current node + pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self { + self.edge_with_desc(pattern, target, None) + } + + /// Add an edge with description + pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self { + let current = self.current_node.expect("No current node selected"); + + // Create target node if it doesn't exist + if !self.nodes.contains_key(target) { + let node = Rc::new(RefCell::new(Node { + id: target, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(target, node); + } + + // Add edge to current node + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().edges.push(Edge { + pattern, + target, + description: desc, + }); + } + + self + } + + /// Set handler for current node + pub fn handler(self, handler: F) -> Self + where + F: Fn(&str, &str, &HashMap) -> ParserAction + 'static + { + let current = self.current_node.expect("No current node selected"); + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().handler = Some(Box::new(handler)); + } + self + } + + /// Build the final graph + pub fn build(self) -> Graph { + Graph { + nodes: self.nodes, + root: self.root, + } + } +} + +// --- Parser Implementation --- + +impl Graph { + pub fn parse(&self, input: &str) -> ParserAction { + let normalized = input.trim().to_lowercase(); + let mut state = ParserState { + input: &normalized, + cursor: 0, + current_node_id: self.root, + context: HashMap::new(), + original_query: input.to_string(), + current_prefix: String::new(), + }; + + self.parse_recursive(&mut state) + } + + fn parse_recursive(&self, state: &mut ParserState) -> ParserAction { + let node = self.nodes.get(state.current_node_id) + .expect("Node not found in graph"); + let node_ref = node.borrow(); + + // If we've consumed all input, check for handler or suggestions + if state.cursor >= state.input.len() { + if let Some(handler) = &node_ref.handler { + return handler(&state.original_query, &state.current_prefix, &state.context); + } + + // No handler, try to suggest based on available edges + return self.suggest_from_edges(&node_ref, state); + } + + let remaining = &state.input[state.cursor..]; + + // Try to match each edge + for edge in &node_ref.edges { + if let Some((consumed, captured)) = edge.pattern.matches(remaining) { + // Save state for potential backtracking + let saved_cursor = state.cursor; + let saved_node = state.current_node_id; + let saved_prefix = state.current_prefix.clone(); + + // Update state + state.cursor += consumed; + state.current_node_id = edge.target; + state.current_prefix.push_str(&remaining[..consumed]); + + // Store captured variable if any + if let Some(value) = captured { + if let EdgePattern::Variable(var_name) = &edge.pattern { + state.context.insert(var_name.to_string(), value); + } + } + + // Check if this is a partial match that needs completion + if state.cursor == state.input.len() { + if let Some(completion_suffix) = edge.pattern.completion(remaining) { + // Use the current_prefix plus the completion suffix + let full_completion = format!("{}{}", + state.current_prefix, + completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix) + ); + return ParserAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + + // Continue parsing from the target node + let result = self.parse_recursive(state); + + // If we got a valid response, return it + if !matches!(result, ParserAction::ShowError(_)) { + return result; + } + + // Otherwise, restore state and try next edge + state.cursor = saved_cursor; + state.current_node_id = saved_node; + state.current_prefix = saved_prefix; + } + } + + // No edges matched - try to provide suggestions + self.suggest_from_edges(&node_ref, state) + } + + fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> ParserAction { + let remaining = &state.input[state.cursor..]; + + // Find edges that could match with more input + for edge in &node.edges { + match &edge.pattern { + EdgePattern::PrefixOf(target) => { + if target.starts_with(remaining) && !remaining.is_empty() { + // Use current_prefix instead of rebuilding from input + let full_completion = format!("{}{}", state.current_prefix, target); + return ParserAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + EdgePattern::Literal(lit) => { + if lit.starts_with(remaining) && !remaining.is_empty() { + let full_completion = format!("{}{}", state.current_prefix, lit); + return ParserAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + _ => {} + } + } + + ParserAction::error( + "InvalidPath".to_string(), + format!("'{}' doesn't match any known pattern", state.original_query) + ) + } +} + +struct ParserState<'a> { + input: &'a str, + cursor: usize, + current_node_id: NodeId, + context: HashMap, + original_query: String, + current_prefix: String, +} + +// --- Helper Functions --- + +fn is_valid_variable(var_name: &str, value: &str) -> bool { + match var_name { + "subreddit" => { + !value.is_empty() && + value.len() <= 21 && + value.chars().all(|c| c.is_alphanumeric() || c == '_') + } + "username" => { + !value.is_empty() && + value.len() <= 20 && + value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') + } + "post_id" => { + !value.is_empty() && + value.len() <= 10 && + value.chars().all(|c| c.is_alphanumeric()) + } + _ => true, // Allow any value for unknown variables + } +} + +// --- Define the Reddit Graph --- + +pub fn build_reddit_graph() -> Graph { + GraphBuilder::new() + // === ROOT LEVEL: Direct aliases and domain/protocol patterns === + .at("root") + // Direct aliases to subreddit and user selection + .edge_with_desc( + EdgePattern::PrefixOf("r/"), + "subreddit_selection", + Some("Browse subreddits (e.g., r/programming)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("u/"), + "user_selection", + Some("Browse users (e.g., u/spez)") + ) + + // Reddit shortcuts - one pattern handles ALL prefixes! + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Go to Reddit") + ) + + // Protocol patterns - "h" can suggest "https://" + .edge_with_desc( + EdgePattern::PrefixOf("https://"), + "https_protocol", + Some("HTTPS protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("http://"), + "http_protocol", + Some("HTTP protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "www_prefix", + Some("World Wide Web") + ) + + + + // === HTTPS PROTOCOL: Can go to any domain === + .at("https_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTPS)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "https_www", + Some("WWW prefix") + ) + + // === HTTP PROTOCOL: Similar to HTTPS === + .at("http_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTP)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "http_www", + Some("WWW prefix") + ) + + // === HTTPS + WWW === + .at("https_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === HTTP + WWW === + .at("http_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === WWW PREFIX (without protocol) === + .at("www_prefix") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === REDDIT DOMAIN: Expect "/" === + .at("reddit_domain") + .edge(EdgePattern::Literal("/"), "reddit_root") + .handler(|query, prefix, _ctx| { + // If someone just types "reddit.com" (or with protocol) without slash + // Suggest adding the slash using the current prefix + let completion = format!("{}/", prefix); + + ParserAction::suggest( + query.to_string(), + Some(Suggestion { + text: completion.clone(), + completion, + description: Some("Continue to Reddit homepage".to_string()), + score: 1.0, + }) + ) + }) + + // === REDDIT ROOT: The main Reddit navigation === + .at("reddit_root") + .edge(EdgePattern::Literal("r/"), "subreddit_selection") + .edge(EdgePattern::Literal("u/"), "user_selection") + .handler(|query, prefix, _ctx| { + ParserAction::multiple(vec![ + ParserAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}r/", prefix), + }, + ScrollingSuggestion { + completion: format!("{}u/", prefix), + }, + ], + 1400, // 1.4 second interval (slower) + true // loop through + ), + ParserAction::guide( + query.to_string(), + "Welcome to Sorter for Reddit".to_string(), + "Where would you like to start?".to_string(), + vec![ + GuideOption { + key: "r".to_string(), + label: "Sort a Subreddit".to_string(), + description: "Find the best posts in a community.".to_string(), + completion: format!("{}r/", prefix) + }, + GuideOption { + key: "u".to_string(), + label: "Sort User Content".to_string(), + description: "Explore and rank a user's posts and comments.".to_string(), + completion: format!("{}u/", prefix) + }, + ] + ), + ]) + }) + + + + // === SUBREDDIT SELECTION: THE UNIFIED NODE === + // This node is now reached from `r/` OR `reddit.com/r/` + .at("subreddit_selection") + .edge(EdgePattern::Variable("subreddit"), "subreddit_page") + .handler(|query, prefix, _ctx| { + ParserAction::multiple(vec![ + ParserAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}programming", prefix), + }, + ScrollingSuggestion { + completion: format!("{}askreddit", prefix), + }, + ScrollingSuggestion { + completion: format!("{}aww", prefix), + }, + ScrollingSuggestion { + completion: format!("{}rust", prefix), + }, + ScrollingSuggestion { + completion: format!("{}webdev", prefix), + }, + ], + 1600, // 1.6 second interval (slower) + true // loop through + ), + // Live DB-backed suggestions for subreddits as the user types + ParserAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() } + ]) + }) + + // === Specific subreddit page === + .at("subreddit_page") + .edge(EdgePattern::Literal("/"), "subreddit_slash") + .handler(|_query, prefix, ctx| { + // Use the new unified subreddit resolution logic + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::ResolveAndDisplaySubreddit { + subreddit, + prefix: prefix.to_string(), + } + }) + + .at("subreddit_slash") + .edge(EdgePattern::Literal("hot"), "subreddit_hot") + .edge(EdgePattern::Literal("top"), "subreddit_top") + .edge(EdgePattern::Literal("new"), "subreddit_new") + .edge(EdgePattern::Literal("comments"), "subreddit_comments") + .handler(|query, prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::guide( + query.to_string(), + format!("What to sort in r/{}?", subreddit), + "Choose a category to begin sorting.".to_string(), + vec![ + GuideOption { + key: "hot".to_string(), + label: "Hot Posts".to_string(), + description: "Import and sort posts currently on the front page.".to_string(), + completion: format!("{}hot", prefix) + }, + GuideOption { + key: "top".to_string(), + label: "Top Posts".to_string(), + description: "Import and sort the highest-rated posts.".to_string(), + completion: format!("{}top", prefix) + }, + GuideOption { + key: "new".to_string(), + label: "New Posts".to_string(), + description: "Import and sort the newest posts.".to_string(), + completion: format!("{}new", prefix) + }, + GuideOption { + key: "comments".to_string(), + label: "All Comments".to_string(), + description: "Find the best comment across all imported threads.".to_string(), + completion: format!("{}comments/", prefix) + }, + ] + ) + }) + + + + .at("user_selection") + .edge(EdgePattern::Variable("username"), "user_profile") + .handler(|query, prefix, _ctx| { + ParserAction::guide( + query.to_string(), + "User Profile Sorting".to_string(), + "Enter a Reddit username to sort their content.".to_string(), + vec![ + GuideOption { + key: "popular".to_string(), + label: "Popular Users".to_string(), + description: "Browse well-known Reddit users.".to_string(), + completion: prefix.to_string(), + }, + ] + ) + }) + + // === Subreddit sort types === + .at("subreddit_hot") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_top") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_new") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_comments") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + ParserAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + // === User profile === + .at("user_profile") + .handler(|_query, _prefix, ctx| { + let username = ctx.get("username").cloned().unwrap_or_default(); + ParserAction::RenderEntityView { + ns: "reddit.user".to_string(), + pk: username, + } + }) + + // === Build the graph === + .build() +} + +// --- Public API --- + +/// Parse a query string and return a UI action +pub fn parse_reddit_url(query: &str) -> ParserAction { + let graph = build_reddit_graph(); + graph.parse(query) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Represents a keystroke action + #[derive(Debug, Clone, PartialEq)] + enum KeyAction { + Type(String), // Type characters + Tab, // Press tab (accept completion) + + } + + /// Expected state after a keystroke + #[derive(Debug, Clone)] + enum ExpectedAction { + Suggestion { completion: String }, + ScrollingSuggestions { completions: Vec }, + Guide { title_contains: String }, + + RenderSubreddit { subreddit: String, sort: Option }, + RenderSubredditComments { subreddit: String }, + RenderUser { username: String }, + ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution + Error { error_type: String }, + Multiple { expected_actions: Vec }, // Multiple responses with specific expectations + MultipleAny, // Multiple responses (any sub-actions - legacy) + DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions + + } + + impl ExpectedAction { + fn matches(&self, action: &ParserAction) -> bool { + match (self, action) { + (ExpectedAction::Suggestion { completion }, ParserAction::ShowSuggestions(data)) => { + data.suggestion.as_ref() + .map(|s| s.completion == *completion) + .unwrap_or(false) + } + (ExpectedAction::ScrollingSuggestions { completions }, ParserAction::ShowScrollingSuggestions { suggestions, .. }) => { + let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect(); + *completions == actual_completions + } + (ExpectedAction::Guide { title_contains }, ParserAction::ShowStaticGuide { title, .. }) => { + title.contains(title_contains) + } + (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, + ParserAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderSubredditComments { subreddit }, + ParserAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderUser { username }, ParserAction::RenderEntityView { ns, pk }) => { + ns == "reddit.user" && pk == username + } + (ExpectedAction::ResolveSubreddit { subreddit, prefix }, + ParserAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => { + s == subreddit && p == prefix + } + (ExpectedAction::Error { error_type }, ParserAction::ShowError(data)) => { + data.error_type == *error_type + } + (ExpectedAction::Multiple { expected_actions }, ParserAction::ShowMultiple { actions }) => { + // Check that all expected actions are present + if expected_actions.len() != actions.len() { + return false; + } + expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| { + expected.matches(actual) + }) + } + (ExpectedAction::MultipleAny, ParserAction::ShowMultiple { .. }) => true, + (ExpectedAction::DbSuggestions { partial, prefix }, + ParserAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => { + p == partial && pr == prefix + } + + _ => false, + } + } + } + + /// Test helper to simulate a sequence of keystrokes + fn simulate_keystrokes(actions: Vec) -> Vec<(String, ParserAction)> { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + let mut results = Vec::new(); + + for action in actions { + match action { + KeyAction::Type(text) => { + current_text.push_str(&text); + let result = graph.parse(¤t_text); + results.push((current_text.clone(), result)); + } + KeyAction::Tab => { + // Tab accepts the current suggestion if there is one + let result = graph.parse(¤t_text); + if let ParserAction::ShowSuggestions(ref data) = result { + if let Some(ref suggestion) = data.suggestion { + current_text = suggestion.completion.clone(); + let new_result = graph.parse(¤t_text); + results.push((current_text.clone(), new_result)); + } + } + } + + } + } + + results + } + + /// Test a flow using declarative (KeyAction, ExpectedAction) tuples + fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + + println!("\n=== Flow: {} ===", name); + + for (i, (key_action, expected)) in flow.iter().enumerate() { + // Perform the keystroke + match key_action { + KeyAction::Type(text) => { + current_text.push_str(text); + } + KeyAction::Tab => { + // Tab accepts the current suggestion + let result = graph.parse(¤t_text); + if let ParserAction::ShowSuggestions(data) = result { + if let Some(suggestion) = &data.suggestion { + current_text = suggestion.completion.clone(); + } + } + } + } + + // Check the result + let actual_action = graph.parse(¤t_text); + + println!(" Step {}: {:?} -> '{}' -> {:?}", + i + 1, key_action, current_text, actual_action); + + assert!( + expected.matches(&actual_action), + "Flow '{}' failed at step {}\n Expected: {:?}\n Actual: {:?}\n Text: '{}'", + name, i + 1, expected, actual_action, current_text + ); + } + + println!("✓ Flow '{}' passed!", name); + } + + /// Legacy helper for backward compatibility (will be removed) + fn assert_flow( + name: &str, + actions: Vec, + expected_checks: Vec bool>>, + ) { + let results = simulate_keystrokes(actions); + + println!("\n=== Flow: {} ===", name); + for (i, (text, action)) in results.iter().enumerate() { + println!(" Step {}: '{}' -> {:?}", i + 1, text, action); + + if i < expected_checks.len() { + let check = &expected_checks[i]; + assert!( + check(text, action), + "Flow '{}' failed at step {} with text '{}' and action {:?}", + name, i + 1, text, action + ); + } + } + println!("✓ Flow '{}' passed!", name); + } + + #[test] + fn test_https_reddit_tab_flow() { + // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/" + assert_flow( + "HTTPS Reddit with Tab", + vec![ + KeyAction::Type("https://reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // After typing "https://reddit.com", should get a suggestion + text == "https://reddit.com" && matches!(action, ParserAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com/" and show guide + text == "https://reddit.com/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_quick_subreddit_flow() { + // Test: "r" -> TAB -> "rust" (direct alias flow) + assert_flow( + "Quick Subreddit Access", + vec![ + KeyAction::Type("r".to_string()), + KeyAction::Tab, + KeyAction::Type("rust".to_string()), + ], + vec![ + Box::new(|text, action| { + // "r" should suggest "r/" + text == "r" && matches!(action, ParserAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "r/" and show subreddit selection + text == "r/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "r/rust" should resolve the subreddit + text == "r/rust" && matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust") + }), + ], + ); + } + + #[test] + fn test_progressive_completion_flow() { + // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB + let progressive_actions = vec![ + KeyAction::Type("r".to_string()), + KeyAction::Type("e".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("i".to_string()), + KeyAction::Type("t".to_string()), + KeyAction::Tab, + ]; + + let results = simulate_keystrokes(progressive_actions); + + println!("\n=== Progressive Completion Flow ==="); + for (i, (text, action)) in results.iter().enumerate() { + println!(" '{}' -> {:?}", text, action); + + // First step: "r" should suggest "r/" + if i == 0 && text == "r" { + match action { + ParserAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "r/", + "Should suggest r/ at 'r'" + ); + } + _ => panic!("Expected suggestion at 'r'"), + } + } + // Other steps before tab should suggest "reddit.com" + else if i > 0 && i < results.len() - 1 { + match action { + ParserAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "reddit.com", + "Should suggest reddit.com at '{}'", text + ); + } + _ => panic!("Expected suggestion at '{}'", text), + } + } + } + + // After tab, should have "reddit.com" + let (final_text, _) = results.last().unwrap(); + assert_eq!(final_text, "reddit.com"); + println!("✓ Progressive completion flow passed!"); + } + + #[test] + fn test_subreddit_sort_flow() { + // Test navigating to a subreddit and choosing a sort option + assert_flow( + "Subreddit Sort Navigation", + vec![ + KeyAction::Type("reddit.com/r/programming/hot".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/r/programming/hot" && + matches!(action, ParserAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_user_profile_flow() { + // Test navigating to a user profile + assert_flow( + "User Profile Navigation", + vec![ + KeyAction::Type("reddit.com/u/spez".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/u/spez" && + matches!(action, ParserAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez") + }), + ], + ); + } + + #[test] + fn test_alias_shortcut_flow() { + // Test using the "r/" shortcut - now correctly goes directly to subreddit + assert_flow( + "Alias Shortcut", + vec![ + KeyAction::Type("r/".to_string()), + KeyAction::Type("technology".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "r/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + text == "r/technology" && + matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology") + }), + ], + ); + } + + #[test] + fn test_www_prefix_flow() { + // Test with www prefix + assert_flow( + "WWW Prefix", + vec![ + KeyAction::Type("www.reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // Should suggest adding slash + text == "www.reddit.com" && matches!(action, ParserAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should show guide + text == "www.reddit.com/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_invalid_path_handling() { + // Test that invalid paths show errors + assert_flow( + "Invalid Path", + vec![ + KeyAction::Type("reddit.com/invalid/path".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/invalid/path" && + matches!(action, ParserAction::ShowError(_)) + }), + ], + ); + } + + #[test] + fn test_complete_user_journey() { + // Test a complete user journey: type partial URL, tab complete, navigate to subreddit + assert_flow( + "Complete User Journey", + vec![ + KeyAction::Type("http".to_string()), + KeyAction::Type("s://r".to_string()), + KeyAction::Tab, + KeyAction::Type("/".to_string()), + KeyAction::Type("r/".to_string()), + KeyAction::Type("programming".to_string()), + KeyAction::Type("/".to_string()), + KeyAction::Type("top".to_string()), + ], + vec![ + Box::new(|text, action| { + // "http" should suggest "https://" + text == "http" && matches!(action, ParserAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // "https://r" should suggest "https://reddit.com" + text == "https://r" && matches!(action, ParserAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com" + text == "https://reddit.com" && matches!(action, ParserAction::ShowSuggestions(_)) + }), + Box::new(|text, action| { + // "https://reddit.com/" should show guide + text == "https://reddit.com/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/" should show subreddit selection + text == "https://reddit.com/r/" && matches!(action, ParserAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming" should resolve subreddit + text == "https://reddit.com/r/programming" && + matches!(action, ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } + if subreddit == "programming" && prefix == "https://reddit.com/r/programming") + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/" should show sort options + text == "https://reddit.com/r/programming/" && + matches!(action, ParserAction::ShowStaticGuide { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/top" should render entity view + text == "https://reddit.com/r/programming/top" && + matches!(action, ParserAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_multiple_tab_completions() { + // Test multiple tab completions in sequence + let actions = vec![ + KeyAction::Type("h".to_string()), + KeyAction::Tab, // Complete to "https://" + KeyAction::Type("r".to_string()), + KeyAction::Tab, // Complete to "https://reddit.com" + KeyAction::Type("/".to_string()), + ]; + + let results = simulate_keystrokes(actions); + + println!("\n=== Multiple Tab Completions ==="); + for (i, (text, _action)) in results.iter().enumerate() { + println!(" Step {}: '{}'", i + 1, text); + } + + // Verify the final state + assert_eq!(results[1].0, "https://"); // After first tab + assert_eq!(results[3].0, "https://reddit.com"); // After second tab + assert_eq!(results[4].0, "https://reddit.com/"); // After typing / + + println!("✓ Multiple tab completions work correctly!"); + } + + // === CONVENIENCE MACROS FOR CLEANER TESTS === + + macro_rules! flow { + ($(($key:expr, $expected:expr)),* $(,)?) => { + vec![$(($key, $expected)),*] + }; + } + + macro_rules! type_text { + ($text:expr) => { + KeyAction::Type($text.to_string()) + }; + } + + macro_rules! suggests { + ($completion:expr) => { + ExpectedAction::Suggestion { completion: $completion.to_string() } + }; + } + + macro_rules! renders_subreddit { + ($subreddit:expr) => { + ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None } + }; + ($subreddit:expr, $sort:expr) => { + ExpectedAction::RenderSubreddit { + subreddit: $subreddit.to_string(), + sort: Some($sort.to_string()) + } + }; + } + + macro_rules! renders_subreddit_comments { + ($subreddit:expr) => { + ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() } + }; + } + + macro_rules! resolves_subreddit { + ($subreddit:expr, $prefix:expr) => { + ExpectedAction::ResolveSubreddit { + subreddit: $subreddit.to_string(), + prefix: $prefix.to_string() + } + }; + } + + macro_rules! shows_guide { + ($title_contains:expr) => { + ExpectedAction::Guide { title_contains: $title_contains.to_string() } + }; + } + + macro_rules! multiple { + ($($action:expr),* $(,)?) => { + ExpectedAction::Multiple { expected_actions: vec![$($action),*] } + }; + } + + macro_rules! scrolling_suggestions { + ($($completion:expr),* $(,)?) => { + ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] } + }; + } + + macro_rules! db_suggestions { + ($partial:expr, $prefix:expr) => { + ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() } + }; + } + + // === NEW DECLARATIVE TESTS === + + #[test] + fn test_declarative_https_tab_flow() { + test_flow("HTTPS Tab Completion", vec![ + (KeyAction::Type("https://reddit.com".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Tab, + multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_declarative_quick_subreddit_flow() { + test_flow("Quick Subreddit Flow", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Tab, + ExpectedAction::MultipleAny), // r/ shows subreddit selection + (KeyAction::Type("rust".to_string()), + ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }), + ]); + } + + #[test] + fn test_declarative_complete_journey() { + test_flow("Complete User Journey", vec![ + (KeyAction::Type("http".to_string()), + ExpectedAction::Suggestion { completion: "https://".to_string() }), + (KeyAction::Type("s://r".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Type("/r/programming/top".to_string()), + ExpectedAction::RenderSubreddit { + subreddit: "programming".to_string(), + sort: Some("top".to_string()) + }), + ]); + } + + #[test] + fn test_declarative_user_profile() { + test_flow("User Profile Navigation", vec![ + (KeyAction::Type("reddit.com/u/spez".to_string()), + ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + #[test] + fn test_declarative_error_handling() { + test_flow("Error Handling", vec![ + (KeyAction::Type("reddit.com/invalid/path".to_string()), + ExpectedAction::Error { error_type: "InvalidPath".to_string() }), + ]); + } + + #[test] + fn test_declarative_progressive_completion() { + test_flow("Progressive Completion", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Type("e".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("d".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("dit".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }), + ]); + } + + #[test] + fn test_declarative_subreddit_guide() { + test_flow("Subreddit Guide", vec![ + (KeyAction::Type("reddit.com/r/programming/".to_string()), + ExpectedAction::Guide { title_contains: "What to sort".to_string() }), + ]); + } + + #[test] + fn test_clean_macro_example() { + // This is what the tests can look like with macros! + test_flow("Clean Macro Example", flow![ + (type_text!("r"), suggests!("r/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + (type_text!("rust/hot"), renders_subreddit!("rust", "hot")), + ]); + } + + #[test] + fn test_unified_subreddit_resolution_flow() { + // This test demonstrates the new unified behavior: + // - r/programming (exact) → tries exact match first + // - r/pro (partial) → tries exact match, then suggestions + TAB completion + + test_flow("Unified Resolution: Exact Match", flow![ + (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")), + ]); + + test_flow("Unified Resolution: Partial Match", flow![ + (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")), + ]); + + // Both go through the same action type, but dispatcher handles them differently: + // - If "programming" exists in DB → shows EntityView immediately + // - If "pro" doesn't exist in DB → shows Multiple with: + // 1. Suggestions (for TAB completion to best match) + // 2. Selection (for clickable options including import) + } + + #[test] + fn test_tab_completion_workflow_unified() { + // This demonstrates the desired TAB completion behavior: + // r/pro + TAB → r/programming (if "programming" is the best DB match) + + // Note: This test shows the PARSER behavior. The actual TAB completion + // happens in the frontend when it receives the Multiple response containing + // both Suggestions (for TAB) and Selection (for click options). + + let graph = build_reddit_graph(); + + // 1. Parser generates unified action for partial input + match graph.parse("r/pro") { + ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "pro"); + assert_eq!(prefix, "r/pro"); + println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"), + } + + // 2. When dispatcher runs (in real app), it will return Multiple response with: + // - Suggestions: { completion: "r/programming" } (for TAB) + // - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks) + + println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)"); + } + + #[test] + fn test_ultra_clean_user_journey() { + test_flow("Ultra Clean User Journey", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("ttps://r"), suggests!("https://reddit.com")), + (KeyAction::Tab, suggests!("https://reddit.com/")), + (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + // === TESTS FROM parser.tdsl === + + #[test] + fn test_tdsl_basic_reddit_progression() { + // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps + test_flow("TDSL Basic Reddit Progression", flow![ + (type_text!("r"), suggests!("r/")), + (type_text!("e"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("i"), suggests!("reddit.com")), + (type_text!("t"), suggests!("reddit.com")), + (type_text!("."), suggests!("reddit.com")), + (type_text!("c"), suggests!("reddit.com")), + (type_text!("o"), suggests!("reddit.com")), + (KeyAction::Tab, suggests!("reddit.com/")), + ]); + } + + #[test] + fn test_tdsl_reddit_com_slash_infographic() { + // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} + test_flow("TDSL Reddit.com/ Infographic", flow![ + (type_text!("reddit.com/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_selection() { + // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} + test_flow("TDSL Subreddit Selection", flow![ + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_view() { + // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)} + test_flow("TDSL Subreddit View", flow![ + (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")), + ]); + } + + #[test] + fn test_tdsl_subreddit_slash_infographic() { + // reddit.com/r/{sub}/ -> {show infographic or something} + test_flow("TDSL Subreddit Slash Infographic", flow![ + (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")), + ]); + } + + #[test] + fn test_tdsl_subreddit_comments() { + // reddit.com/r/{sub}/comments/{randomly chose comment from sql} + test_flow("TDSL Subreddit Comments", flow![ + (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")), + ]); + } + + #[test] + fn test_tdsl_h_to_https() { + // h->https:// (show supported domains) + test_flow("TDSL H to HTTPS", flow![ + (type_text!("h"), suggests!("https://")), + ]); + } + + #[test] + fn test_tdsl_composable_https_reddit() { + // https://r->https://reddit.com/ + test_flow("TDSL Composable HTTPS Reddit", flow![ + (type_text!("https://r"), suggests!("https://reddit.com")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www() { + // https://w->https://www. + test_flow("TDSL Composable HTTPS WWW", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www_reddit() { + // https://www.r->https://www.reddit.com/ + test_flow("TDSL Composable HTTPS WWW Reddit", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_full_composable_chain() { + // Complete chain showing composability: h -> https:// -> https://www.reddit.com + test_flow("TDSL Full Composable Chain Step 1", flow![ + (type_text!("h"), suggests!("https://")), + ]); + + test_flow("TDSL Full Composable Chain Step 2", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + + test_flow("TDSL Full Composable Chain Step 3", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_progressive_reddit_paths() { + // Test various reddit paths work as expected + test_flow("TDSL Progressive Reddit Paths", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_sorting_options() { + // Test that subreddit sorting options work as described + test_flow("TDSL Subreddit Sorting", flow![ + (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")), + ]); + + test_flow("TDSL Subreddit Top", flow![ + (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")), + ]); + + test_flow("TDSL Subreddit New", flow![ + (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")), + ]); + } + + #[test] + fn test_tdsl_all_reddit_prefixes() { + // Test all the prefixes mentioned in parser.tdsl work + // "r" now suggests "r/", all others suggest "reddit.com" + let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + // Test "r" separately since it now suggests "r/" + test_flow("TDSL Prefix: r", flow![ + (type_text!("r"), suggests!("r/")), + ]); + + for prefix in prefixes_to_reddit { + test_flow(&format!("TDSL Prefix: {}", prefix), flow![ + (type_text!(prefix), suggests!("reddit.com")), + ]); + } + } + + #[test] + fn test_tdsl_protocol_combinations() { + // Test various protocol combinations from parser.tdsl + let test_cases = vec![ + ("http://r", "http://reddit.com"), + ("https://r", "https://reddit.com"), + ("www.r", "www.reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("http://www.r", "http://www.reddit.com"), + ]; + + for (input, expected) in test_cases { + test_flow(&format!("TDSL Protocol: {}", input), flow![ + (type_text!(input), suggests!(expected)), + ]); + } + } + + #[test] + fn test_tdsl_edge_case_completions() { + // Test edge cases mentioned in parser.tdsl + test_flow("TDSL Reddit.com completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + ]); + + // Test that typing full reddit.com suggests the slash + test_flow("TDSL Full domain completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_real_user_trace_2025_08_07_fixed() { + // Based on actual WebSocket trace from 2025-08-07T01:35:46Z + // This test shows the CORRECT behavior after fixing the protocol preservation bug + test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![ + // User started typing "h" + (type_text!("h"), suggests!("https://")), + + // User continued to "ht" + (type_text!("t"), suggests!("https://")), + + // User finished typing "https://" (trace shows full protocol) + (type_text!("tps://"), suggests!("https://")), + + // User started typing "r" after protocol + (type_text!("r"), suggests!("https://reddit.com")), + + // User continued typing "re" + (type_text!("e"), suggests!("https://reddit.com")), + + // User typed out or completed "https://reddit.com" + (type_text!("ddit.com"), suggests!("https://reddit.com/")), + + // User accepted completion to "https://reddit.com/" + // NOW suggestions should preserve the https:// protocol + (KeyAction::Tab, multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // This is the key fix! + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_protocol_preservation_bug_fix() { + // This test specifically verifies the fix for the protocol preservation bug + test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![ + (type_text!("https://reddit.com/"), multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // Should preserve https:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that subreddit selection preserves protocol + test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![ + (type_text!("https://reddit.com/r/"), multiple![ + 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:// + db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/") + ]), + ]); + + // Test with different protocols + test_flow("Protocol Preservation: HTTP", flow![ + (type_text!("http://reddit.com/"), multiple![ + scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"), // Should preserve http:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + test_flow("Protocol Preservation: WWW", flow![ + (type_text!("www.reddit.com/"), multiple![ + scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"), // Should preserve www. + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that plain reddit.com still works + test_flow("Protocol Preservation: Plain Domain", flow![ + (type_text!("reddit.com/"), multiple![ + scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"), // No protocol prefix + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_user_navigation_pattern() { + // Models how users actually navigate: type → tab → click suggestion → end up at destination + // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace + test_flow("User Navigation: Protocol to Domain", flow![ + // User types and gets to homepage + (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny), + ]); + + // Then they navigate (perhaps clicking a suggestion) to subreddit selection + test_flow("User Navigation: Click to Subreddit", flow![ + // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/" + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_progressive_typing_pattern() { + // Based on the trace pattern - users often type character by character + // This tests the exact sequence of suggestions they would see + test_flow("Progressive Typing Pattern", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("t"), suggests!("https://")), // Still suggests https:// + (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https:// + (type_text!("p"), suggests!("https://")), // http should still suggest https:// + (type_text!("s"), suggests!("https://")), // https should suggest https:// + (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself + ]); + } + + #[test] + fn test_tab_completion_workflow() { + // Test what happens when user uses tab completions strategically + test_flow("Strategic Tab Completion Workflow", flow![ + // Start typing, get suggestion + (type_text!("h"), suggests!("https://")), + + // Accept suggestion with tab - this should move us to "https://" + (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself + + // Start typing reddit + (type_text!("r"), suggests!("https://reddit.com")), + + // Accept reddit suggestion + (KeyAction::Tab, suggests!("https://reddit.com/")), + + // Accept final suggestion to get to homepage + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + // Keep the original tests as well + #[test] + fn test_reddit_prefix_autocomplete() { + let graph = build_reddit_graph(); + + // "r" now suggests "r/", others suggest "reddit.com" + match graph.parse("r") { + ParserAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/"); + println!("✓ 'r' → r/"); + } + _ => panic!("Expected suggestion for 'r'"), + } + + // Test other prefixes that should suggest "reddit.com" + let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + for prefix in prefixes { + match graph.parse(prefix) { + ParserAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com"); + println!("✓ '{}' → reddit.com", prefix); + } + _ => panic!("Expected suggestion for '{}'", prefix), + } + } + } + + #[test] + fn test_protocol_composition() { + let graph = build_reddit_graph(); + + // Test protocol + reddit compositions + let tests = vec![ + ("https://r", "https://reddit.com"), + ("https://re", "https://reddit.com"), + ("https://reddit", "https://reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("https://www.reddit", "https://www.reddit.com"), + ("http://r", "http://reddit.com"), + ("www.r", "www.reddit.com"), + ]; + + for (input, expected) in tests { + match graph.parse(input) { + ParserAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, expected); + println!("✓ '{}' → {}", input, expected); + } + _ => panic!("Expected suggestion for '{}'", input), + } + } + } + + #[test] + fn test_alias_and_full_paths() { + let graph = build_reddit_graph(); + + // reddit.com/ should show guide, r/ should show subreddit selection + match graph.parse("reddit.com/") { + ParserAction::ShowMultiple { actions } => { + let has_guide = actions.iter() + .any(|a| matches!(a, ParserAction::ShowStaticGuide { .. })); + assert!(has_guide, "Path 'reddit.com/' should show guide"); + println!("✓ 'reddit.com/' shows Reddit root guide"); + } + _ => panic!("Expected Multiple action for 'reddit.com/'"), + } + + match graph.parse("r/") { + ParserAction::ShowMultiple { actions } => { + let has_scrolling_suggestions = actions.iter() + .any(|a| matches!(a, ParserAction::ShowScrollingSuggestions { .. })); + let has_db_suggestions = actions.iter() + .any(|a| matches!(a, ParserAction::SuggestSubredditsFromDb { .. })); + assert!(has_scrolling_suggestions && has_db_suggestions, + "Path 'r/' should show scrolling suggestions and DB suggestions"); + println!("✓ 'r/' shows subreddit selection"); + } + _ => panic!("Expected Multiple action for 'r/'"), + } + } + + #[test] + fn test_deep_navigation() { + let graph = build_reddit_graph(); + + // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit + match graph.parse("reddit.com/r/rust") { + ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "rust"); + assert_eq!(prefix, "reddit.com/r/rust"); + println!("✓ reddit.com/r/rust recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"), + } + + // Test with alias (r/ goes directly to subreddit, no double r/) + match graph.parse("r/programming") { + ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "programming"); + assert_eq!(prefix, "r/programming"); + println!("✓ r/programming (alias) recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"), + } + } +} diff --git a/server/src/parser_action.rs b/server/src/parser_action.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b98556b562d548ce231a726f9e704f595e8f4a1 --- /dev/null +++ b/server/src/parser_action.rs @@ -0,0 +1,124 @@ +//! Parser output actions — graph handlers return these; HTML render turns them into markup. + +#[derive(Debug, Clone, PartialEq)] +pub struct Suggestion { + pub text: String, + pub completion: String, + pub description: Option, + pub score: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ScrollingSuggestion { + pub completion: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GuideOption { + pub key: String, + pub label: String, + pub description: String, + pub completion: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SuggestionsData { + pub query: String, + pub suggestion: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ErrorData { + pub error_type: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ParserAction { + ShowSuggestions(SuggestionsData), + ShowScrollingSuggestions { + query: String, + suggestions: Vec, + interval_ms: u64, + r#loop: bool, + }, + ShowStaticGuide { + query: String, + title: String, + subtitle: String, + options: Vec, + }, + ShowMultiple { + actions: Vec, + }, + ShowError(ErrorData), + SuggestSubredditsFromDb { + partial: String, + prefix: String, + }, + ResolveAndDisplaySubreddit { + subreddit: String, + prefix: String, + }, + RenderEntityView { + ns: String, + pk: String, + }, +} + +impl ParserAction { + pub fn suggest(query: String, suggestion: Option) -> Self { + Self::ShowSuggestions(SuggestionsData { query, suggestion }) + } + + pub fn error(error_type: String, message: String) -> Self { + Self::ShowError(ErrorData { + error_type, + message, + }) + } + + pub fn multiple(actions: Vec) -> Self { + Self::ShowMultiple { actions } + } + + pub fn scrolling_suggestions( + query: String, + suggestions: Vec, + interval_ms: u64, + r#loop: bool, + ) -> Self { + Self::ShowScrollingSuggestions { + query, + suggestions, + interval_ms, + r#loop, + } + } + + pub fn guide( + query: String, + title: String, + subtitle: String, + options: Vec, + ) -> Self { + Self::ShowStaticGuide { + query, + title, + subtitle, + options, + } + } + + /// Primary tab-completion string, if any. + pub fn primary_completion(&self) -> Option<&str> { + match self { + Self::ShowSuggestions(data) => data + .suggestion + .as_ref() + .map(|s| s.completion.as_str()), + Self::ShowMultiple { actions } => actions.iter().find_map(|a| a.primary_completion()), + _ => None, + } + } +} diff --git a/server/src/parser_render.rs b/server/src/parser_render.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f21a9eac5aa200946aca54768f884a7b3a23557 --- /dev/null +++ b/server/src/parser_render.rs @@ -0,0 +1,200 @@ +use maud::{html, Markup}; + +use crate::{ + form_template::template_json_compact, + parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion}, + ui_action::UI_RPC_FIELD, +}; + +const SAMPLE_SUBREDDITS: &[&str] = &["programming", "askreddit", "rust", "webdev", "aww"]; + +fn parse_query_rpc_template() -> String { + template_json_compact(&serde_json::json!({ + "action": "parse_query", + "query": {"$form": "query"}, + })) + .expect("parse_query rpc template") +} + +fn completion_button(completion: &str, label: &str, primary: bool) -> Markup { + let class = if primary { + "parser-completion parser-suggestion-primary btn-link" + } else { + "parser-completion btn-link" + }; + html! { + button + type="button" + class=(class) + data-completion=(completion) + title="Use this path" { + (label) + } + } +} + +fn render_suggestion(s: &Suggestion) -> Markup { + let desc = s + .description + .as_deref() + .unwrap_or("Tab to complete"); + html! { + p class="parser-suggestion" { + (completion_button(&s.completion, &s.completion, true)) + span class="muted small" { " — " (desc) } + } + } +} + +fn render_scrolling(suggestions: &[ScrollingSuggestion]) -> Markup { + html! { + div class="parser-scrolling muted small" { + p { "Examples:" } + ul class="parser-scroll-list" { + @for s in suggestions { + li { (completion_button(&s.completion, &s.completion, false)) } + } + } + } + } +} + +fn render_guide(title: &str, subtitle: &str, options: &[GuideOption]) -> Markup { + html! { + div class="parser-guide" { + h3 { (title) } + p class="muted" { (subtitle) } + ul class="parser-guide-list" { + @for opt in options { + li { + strong { (opt.key) ": " } + (completion_button(&opt.completion, &opt.label, false)) + span class="muted small" { " — " (opt.description) } + } + } + } + } + } +} + +fn render_db_subs(partial: &str, prefix: &str) -> Markup { + let needle = partial.to_lowercase(); + let matches: Vec<_> = SAMPLE_SUBREDDITS + .iter() + .filter(|s| s.contains(&needle) || prefix.ends_with('/') && needle.is_empty()) + .take(6) + .collect(); + html! { + div class="parser-db-subs muted small" { + p { "Subreddits:" } + ul { + @for sub in matches { + @let completion = format!("{prefix}{sub}"); + li { (completion_button(&completion, &format!("r/{sub}"), false)) } + } + } + } + } +} + +fn render_action(action: &ParserAction) -> Markup { + match action { + ParserAction::ShowSuggestions(data) => html! { + div class="parser-result parser-suggestions" { + @if let Some(s) = &data.suggestion { + (render_suggestion(s)) + } @else { + p class="muted" { "No completion" } + } + } + }, + ParserAction::ShowScrollingSuggestions { suggestions, .. } => { + render_scrolling(suggestions) + } + ParserAction::ShowStaticGuide { + title, + subtitle, + options, + .. + } => render_guide(title, subtitle, options), + ParserAction::ShowMultiple { actions } => html! { + div class="parser-multiple" { + @for a in actions { + (render_action(a)) + } + } + }, + ParserAction::ShowError(data) => html! { + p class="parser-error muted" { + strong { (data.error_type) ": " } + (data.message) + } + }, + ParserAction::SuggestSubredditsFromDb { partial, prefix } => { + render_db_subs(partial, prefix) + } + ParserAction::ResolveAndDisplaySubreddit { subreddit, prefix } => html! { + div class="parser-resolve" { + p { + "Subreddit " + strong { "r/" (subreddit) } + @if subreddit.len() <= 3 { + span class="muted small" { " (partial — tab or pick a match)" } + } + } + (render_db_subs(subreddit, prefix)) + } + }, + ParserAction::RenderEntityView { ns, pk } => html! { + div class="parser-entity" { + p { + "Would open " + code { (ns) "/" (pk) } + } + } + }, + } +} + +/// Parser output panel (inner content for `#parser-panel`). +pub fn parser_panel(query: &str, action: &ParserAction) -> Markup { + html! { + section id="parser-panel" class="demo-panel" { + h2 { "Navigate" } + p class="muted small" { + "Type a Reddit path — " + code { "r/rust" } + ", " + code { "reddit.com/r/programming/hot" } + ", etc. Tab completes; each keystroke posts " + code { "__rpc__" } + " to " + code { "/ui" } + "." + } + form method="post" action="/ui" id="parser-form" { + input + type="text" + name="query" + id="parser-input" + value=(query) + placeholder="r/ or reddit.com/…" + autocomplete="off" + spellcheck="false"; + input type="hidden" name=(UI_RPC_FIELD) value=(parse_query_rpc_template()); + } + div id="parser-output" { + @if query.is_empty() { + p class="muted" { "Start typing…" } + } @else { + (render_action(action)) + } + } + } + } +} + +/// Wrap panel HTML for Idiomorph (morph `#parser-panel` only). +pub fn parser_panel_morph(query: &str, action: &ParserAction) -> Markup { + parser_panel(query, action) +} diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs index 58ddab733b32f7b624e699c5874bf89e41c5e188..1d488e1ad1d8fd81bd9d016d69255adfe9b22fc8 100644 --- a/server/src/ui_action.rs +++ b/server/src/ui_action.rs @@ -21,6 +21,10 @@ pub enum HtmlUiAction { ratio_left: i32, ratio_right: i32, }, + /// Parse address-bar query via Reddit transition graph; morph `#parser-panel`. + ParseQuery { + query: String, + }, } #[derive(Debug, Error)] @@ -86,4 +90,24 @@ mod tests { } ); } + + #[test] + fn parse_query_round_trip_with_form_hole() { + let template = serde_json::json!({ + "action": "parse_query", + "query": {"$form": "query"}, + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + form.insert("query".into(), "r/rust".into()); + assert_eq!( + parse_html_ui_from_form(&form).unwrap(), + HtmlUiAction::ParseQuery { + query: "r/rust".into(), + } + ); + } } diff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js index 5cdf32836faf4b06930bcf048cf8cb1b1c1e574e..a00b662e1f8ea75a07521e70a3e74799726878bc 100644 --- a/server/static/sorter_ui.js +++ b/server/static/sorter_ui.js @@ -8,6 +8,27 @@ } } + function postUiForm(form) { + return fetch(form.action, { + method: 'POST', + body: new URLSearchParams(new FormData(form)), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + }).then(function (resp) { + return resp.text(); + }).then(evalJs); + } + + var parserTimer = null; + + function scheduleParserInput(input) { + if (parserTimer) clearTimeout(parserTimer); + parserTimer = setTimeout(function () { + var form = document.getElementById('parser-form'); + if (form) postUiForm(form); + }, 120); + } + function initSorterUi() { document.addEventListener('submit', async function (e) { var f = e.target; @@ -16,13 +37,39 @@ if (f.id === 'sorter-theme-form') return; if (f.getAttribute('data-navigate') === 'full') return; e.preventDefault(); - var resp = await fetch(f.action, { - method: 'POST', - body: new URLSearchParams(new FormData(f)), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - credentials: 'same-origin', - }); - evalJs(await resp.text()); + await postUiForm(f); + }); + + document.addEventListener('input', function (e) { + if (e.target && e.target.id === 'parser-input') { + scheduleParserInput(e.target); + } + }); + + document.addEventListener('keydown', function (e) { + if (!e.target || e.target.id !== 'parser-input') return; + if (e.key !== 'Tab') return; + var completion = + e.target.dataset.completion || + (function () { + var btn = document.querySelector('#parser-output .parser-suggestion-primary'); + return btn && btn.getAttribute('data-completion'); + })(); + if (!completion) return; + e.preventDefault(); + e.target.value = completion; + scheduleParserInput(e.target); + }); + + document.addEventListener('click', function (e) { + var btn = e.target.closest('.parser-completion'); + if (!btn) return; + var input = document.getElementById('parser-input'); + if (!input) return; + var completion = btn.getAttribute('data-completion'); + if (!completion) return; + input.value = completion; + scheduleParserInput(input); }); } diff --git a/server/tests/integration_health.rs b/server/tests/integration_health.rs index 6eda3499eab29233c42c935f2ae449f5b8be9ab2..8a58bc14aac68c779e7ce4ffa57d27b3220e9953 100644 --- a/server/tests/integration_health.rs +++ b/server/tests/integration_health.rs @@ -51,5 +51,6 @@ async fn home_has_demo_panel() { .unwrap(); assert!(html.contains("vote-panel")); assert!(html.contains("ranking-panel")); + assert!(html.contains("parser-panel")); assert!(html.contains("__rpc__")); } diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs index 3bed92095255b63997db9d5437a1fc40d440bb52..bee79407dec120699a083f1a75adf9291e207fc7 100644 --- a/server/tests/integration_ui.rs +++ b/server/tests/integration_ui.rs @@ -95,3 +95,30 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() { assert_eq!(ranked.len(), 2); assert_eq!(ranked[0].item.as_str(), "alpha"); } + +#[tokio::test] +async fn post_ui_parse_query_morphs_parser_panel() { + let (addr, _tmp) = start_test_server().await; + let rpc = serde_json::json!({ + "action": "parse_query", + "query": "r" + }) + .to_string(); + let mut form = HashMap::new(); + form.insert(UI_RPC_FIELD.to_string(), rpc); + + let client = reqwest::Client::new(); + let body = client + .post(format!("http://{addr}/ui")) + .form(&form) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + + assert!(body.contains("Idiomorph.morph")); + assert!(body.contains("parser-panel")); + assert!(body.contains("r/")); +} diff --git a/test/smoke.clj b/test/smoke.clj index 1a8250b5470f0db27aa2c62664fe92726de2cbd2..b332324743b58baf3fe96d7477353749f137feae 100644 --- a/test/smoke.clj +++ b/test/smoke.clj @@ -51,6 +51,7 @@ "curl" "-sf" (str base "/")))] (is (str/includes? home "vote-panel")) (is (str/includes? home "ranking-panel")) + (is (str/includes? home "parser-panel")) (is (str/includes? home "__rpc__"))) (finally (process/destroy proc)))))))