Commit A adds substantial new functionality: a real background Reddit fetch worker with OAuth, rate-limit handling, backoff, JSON parsing for subreddits/posts, and integration into the tree/state, plus tests for URL mapping and parsing. Commit B simplifies the UI by ripping out a large complex (but working, tested) autocomplete graph and replacing it with a simpler paste-and-go parser, which is reasonable cleanup but net removes more functionality/tests than it adds, and its 'value' is largely subtractive/simplifying rather than building new capability.
constitution · epochs · watch · epoch 3
c_c124c217f89c (tommy-mor) vs c_8dc1a8119370 (tommy-mor)
download prompt · raw event · cmp_7db2fb1b5b08b0
council reasoning
Commit A replaces a no-op Reddit placeholder with a complete background broker (OAuth, rate-limit/backoff, dedup, JSON parsers for subreddits/posts, tree integration and fetch-on-browse), delivering durable core import capability. Commit B mainly deletes an over-engineered ~1800-line keystroke graph plus race-prone JS/Playwright harness in favor of a short paste-and-go URL parser; that cleanup is valuable but less foundational than A’s working data-import path.
Side A implements substantial new functionality: a background Reddit fetch broker with request deduplication, rate-limit/backoff handling, optional OAuth, JSON parsing into project entities, integration into application state, and automatic fetch triggering when Reddit items are browsed or created. Side B mainly removes a large autocomplete/transition-graph system in favor of a much simpler paste-and-go URL parser and redirect UI, reducing complexity but largely replacing existing behavior rather than adding enduring infrastructure.
sides
A — c_c124c217f89c (tommy-mor)
message
[8d8230d1] reddit
diff preview
diff --git a/.gitignore b/.gitignore
index 4c7073f9fac0c30fd2050d79a60ef447af58ebeb..ada462e900d24a3a6d08165d158c80f79c35a5a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@
data/
repomix-output.xml
dev-data/
+.env
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 7906a8547d56b8e6a48ef59c37aa82a8510fdee9..4677fedcb45292eebebe7e9cf6ce2f5738f18ddf 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -16,6 +16,7 @@ tower = "0.5"
tower-http = { version = "0.5", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+reqwest = { version = "0.12", features = ["json"] }
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 2864407ed6e8ec284a1dc663acf1805534566b62..df6505021d9f446c2b453e20e3eb3cf696a111f9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -272,5 +272,16 @@ pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse
pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
+ if item.as_str().starts_with("reddit.com") {
+ let needs_fetch = {
+ let tree = state.tree.read().await;
+ tree.get(&item)
+ .map(|n| n.data.is_none())
+ .unwrap_or(true)
+ };
+ if needs_fetch {
+ state.reddit.request_fetch(item.clone());
+ }
+ }
item_page(state, uri, item).await
}
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index d203dca09245daf869b3aa942898447700ae69fb..90053ad03b1d7c8e94f325dd4ee64c2b4f7da900 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -1,4 +1,12 @@
-//! Reddit API import (async, decoupled from UI request path).
+//! Reddit API import via a single background worker (rate limits, dedup, backoff).
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use reqwest::{header, Client, StatusCode};
+use serde::Deserialize;
+use tokio::sync::{mpsc, RwLock};
use crate::{
path_types::ItemId,
@@ -10,12 +18,401 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
tree.ensure_path(id);
}
-/// Placeholder for Reddit JSON import. Returns entity data when implemented.
-pub async fn fetch_reddit_entity(_id: &ItemId) -> Option<EntityData> {
- None
+pub struct RedditCommand {
+ pub id: ItemId,
+}
+
+#[derive(Clone)]
+pub struct RedditBroker {
+ tx: mpsc::Sender<RedditCommand>,
+}
+
+#[derive(Clone)]
+struct RedditCredentials {
+ client_id: String,
+ client_secret: String,
+}
+
+struct OAuthToken {
+ access_token: String,
+ expires_at: Instant,
+}
+
+impl RedditBroker {
+ pub fn spawn(tree: Arc<RwLock<GlobalTree>>, user_agent: &str) -> Self {
+ let (tx, rx) = mpsc::channel(100);
+
+ let mut headers = header::HeaderMap::new();
+ headers.insert(
+ header::USER_AGENT,
+ header::HeaderValue::from_str(user_agent).expect("valid user agent"),
+ );
+
+ let client = Client::builder()
+ .default_headers(headers)
+ .timeout(Duration::from_secs(15))
+ .build()
+ .expect("reqwest client");
+
+ let creds = RedditCredentials::from_env();
+ tokio::spawn(reddit_worker(rx, tree, client, creds));
+
+ Self { tx }
+ }
+
+ /// Fire-and-forget: queue a fetch; worker updates the tree when done.
+ pub fn request_fetch(&self, id: ItemId) {
+ let _ = self.tx.try_send(RedditCommand { id });
+ }
+}
+
+impl RedditCredentials {
+ fn from_env() -> Option<Self> {
+ let client_id = std::env::var("REDDIT_CLIENT_ID").ok()?;
+ let client_secret = std::env::var("REDDIT_CLIENT_SECRET").ok()?;
+ if client_id.is_empty() || client_secret.is_empty() {
+ return None;
+ }
+ Some(Self {
+ client_id,
+ client_secret,
+ })
+ }
+}
+
+pub fn default_user_agent() -> String {
+ std::env::var("REDDIT_USER_AGENT").unwrap_or_else(|_| {
+ "web:sorter2.social:v0.0.1 (by /u/sorter2)".to_string()
+ })
}
-/// Apply fetched entity data to a node (called from async worker).
-pub fn apply_entity(tree: &mut GlobalTree, id: &ItemId, data: EntityData) {
- tree.set_entity_data(id, data);
+async fn reddit_worker(
+ mut rx: mpsc::Receiver<RedditCommand>,
+ tree: Arc<RwLock<GlobalTree>>,
+ client: Client,
+ creds: Option<RedditCredentials>,
+) {
+ let mut in_flight = HashSet::new();
+ let mut recently_fetched: HashMap<ItemId, Instant> = HashMap::new();
+ let mut current_delay = Duration::from_secs(1);
+ let mut oauth: Option<OAuthToken> = None;
+ let cache_ttl = Duration::from_secs(300);
+
+ while let Some(cmd) = rx.recv().await {
+ let now = Instant::now();
+ recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);
+
+ if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {
+ continue;
+ }
+
+ in_flight.insert(cmd.id.clone());
+ let fetch_id = cmd.id.clone();
+
+ tokio::time::sleep(current_delay).await;
+
+ if let Some(c) = &creds {
+ oauth = ensure_oauth_token(&client, c, oauth.take()).await;
+ }
+
+ let token = oauth.as_ref().map(|t| t.access_token.as_str());
+ let use_oauth = token.is_some();
+
+ match do_fetch(&client, &fetch_id, use_oauth, token).await {
+ Ok(FetchOutcome::Entity(data)) => {
+ let mut w = tree.write().await;
+ w.set_entity_data(&fetch_id, data);
+ recently_fetched.insert(fetch_id.clone(), Instant::now());
+ current_delay = Duration::from_millis(600);
+ }
+ Ok(FetchOutcome::NotFound) => {
+ recently_fetched.insert(fetch_id.clone(), Instant::now());
+ }
+ Ok(FetchOutcome::RateLimited { reset_secs }) => {
+ let wait = Duration::from_secs(reset_secs.max(1));
+ tracing::warn!(
+ "Reddit rate limit for {}; sleeping {}s",
+ fetch_id,
+ wait.as_secs()
+ );
+ tokio::time::sleep(wait).await;
+ current_delay = (current_delay * 2).min(Duration::from_secs(60));
+ }
+ Err(e) => {
+ tracing::warn!("Reddit fetch failed for {}: {}", fetch_id, e);
+ current_delay = (current_delay * 2).min(Duration::from_secs(60));
+ }
+ }
+
+ in_flight.remove(&fetch_id);
+ }
+}
+
+enum FetchOutcome {
+ Entity(EntityData),
+ NotFound,
+ RateLimited { reset_secs: u64 },
+}
+
+async fn ensure_oauth_token(
+ client: &Client,
+ creds: &RedditCredentials,
+ existing: Option<OAuthToken>,
+) -> Option<OAuthToken> {
+ if let Some(t) = existing {
+ if Instant::now() < t.expires_at - Duration::from_secs(60) {
+ return Some(t);
+ }
+ }
+
+ let resp = client
+ .post("https://www.reddit.com/api/v1/access_token")
+ .basic_auth(&creds.client_id, Some(&creds.client_secret))
+ .form(&[("grant_type", "client_credentials")])
+ .send()
+ .await;
+
+ let resp = match resp {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!("Reddit OAuth token request failed: {e}");
+ return None;
+ }
+ };
+
+ if !resp.status().is_success() {
+ tracing::warn!("Reddit OAuth token HTTP {}", resp.status());
+ return None;
+ }
+
+ #[derive(Deserialize)]
+ struct TokenResponse {
+ access_token: String,
+ expires_in: u64,
+ }
+
+ let body: TokenResponse = match resp.json().await {
+ Ok(b) => b,
+ Err(e) => {
+ tracing::warn!("Reddit OAuth token parse failed: {e}");
+ return None;
+ }
+ };
+
+ Some(OAuthToken {
+ access_token: body.access_token,
+ expires_at: Instant::now() + Duration::from_secs(body.expires_in),
+ })
+}
+
+async fn do_fetch(
+ client: &Client,
+ id: &ItemId,
+ use_oauth: bool,
+ bearer: Option<&str>,
+) -> Result<FetchOutcome, String> {
+ let url = map_item_to_reddit_api(id, use_oauth);
+ if url.is_empty() {
+ return Ok(FetchOutcome::NotFound);
+ }
+
+ let mut req = client.get(&url);
+ if let Some(token) = bearer {
+ req = req.bearer_auth(token);
+ }
+
+ let resp = req.send().await.map_err(|e| e.to_string())?;
+
+ if resp.status() == StatusCode::TOO_MANY_REQUESTS {
+ let reset = rate_limit_reset_secs(&resp);
+ return Ok(FetchOutcome::RateLimited { reset_secs: reset });
+ }
+
+ if resp.status() == StatusCode::SERVICE_UNAVAILABLE {
+ return Err("Reddit unavailable (503)".to_string());
+ }
+
+ if !resp.status().is_success() {
+ return Ok(FetchOutcome::NotFound);
+ }
+
+ if rate_limit_remaining(&resp) == Some(0) {
+ let reset = rate_limit_reset_secs(&resp);
+ return Ok(FetchOutcome::RateLimited { reset_secs: reset });
+ }
+
+ let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
+ Ok(parse_reddit_json(id, &bytes)
+ .map(FetchOutcome::Entity)
+ .unwrap_or(FetchOutcome::NotFound))
+}
+
+fn rate_limit_remaining(resp: &reqwest::Response) -> Option<u64> {
+ resp.headers()
+ .get("x-ratelimit-remaining")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.parse::<f64>().ok())
+ .map(|f| f.floor() as u64)
+}
+
+fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {
+ resp.headers()
+ .get("x-ratelimit-reset")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.parse::<f64>().ok())
+ .map(|f| f.ceil() as u64)
+ .unwrap_or(5)
+}
+
+/// Map canonical item id to Reddit JSON API URL.
+pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {
+ let path = id.as_str();
+ if !path.starts_with("reddit.com/") && path != "reddit.com" {
+ return String::new();
+ }
+
+ let base = if oauth {
+ "https://oauth.reddit.com"
+ } else {
+ "https://www.reddit.com"
+ };
+
+ let segments: Vec<&str> = path.split('/').collect();
+
+ if let Some(i) = segments.iter().position(|&p| p == "comments") {
+ if segments.len() > i + 1 {
+ let api_path = segments[1..=i + 1].join("/");
+ return format!("{base}/{api_path}.json?raw_json=1");
+ }
+ }
+
+ if segments.len() == 3 && segments[1] == "r" {
+ return format!("{base}/r/{}/about.json?raw_json=1", segments[2]);
+ }
+
+ String::new()
+}
+
+fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option<EntityData> {
+ let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
+ let segments: Vec<&str> = id.as_str().split('/').collect();
+
+ if segments.iter().any(|&p| p == "comments") {
+ parse_post_listing(&v)
+ } else {
+ parse_subreddit_about(&v)
+ }
+}
+
+fn parse_subreddit_about(v: &serde_json::Value) -> Option<EntityData> {
+ let data = v.get("data")?;
+ let title = data
+ .get("title")
+ .or_else(|| data.get("display_name"))
+ .and_then(|t| t.as_str())?
+ .to_string();
+ let body_html = data
+ .get("public_description_html")
+ .or_else(|| data.get("public_description"))
+ .and_then(|t| t.as_str())
+ .map(|s| s.to_string());
+ let thumb_url = data
+ .get("icon_img")
+ .or_else(|| data.get("community_icon"))
+ .and_then(|t| t.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+
+ Some(EntityData {
+ title,
+ author: None,
+ body_html,
+ thumb_url,
+ })
+}
+
+fn parse_post_listing(v:
… preview truncated; 4,570 characters omittedB — c_8dc1a8119370 (tommy-mor)
message
[529cc941] Replace autocomplete parser with paste-and-go navigate. The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
- **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
- **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
- **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
### Commands (see also `TEST.sh`)
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use crate::{
html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
- parser_render::parser_panel_morph,
+ parser_render::navigate_panel,
state::AppState,
ui_action::{parse_html_ui_from_form, HtmlUiAction},
};
@@ -57,18 +57,23 @@ 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)
- ));
+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+ Ok(subreddit) => {
+ let dest = format!("/?sub={subreddit}");
+ JsBuilder::new()
+ .raw(&format!(
+ "window.location.href={};",
+ js_string_literal(&dest)
+ ))
+ .into_response()
}
- js.into_response()
- }
+ Err(message) => {
+ let panel = navigate_panel(&query, Some(&message));
+ JsBuilder::new()
+ .morph_selector("#parser-panel", panel)
+ .into_response()
+ }
+ },
}
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
use crate::{
form_template::template_json_compact,
- parser_action::ParserAction,
- parser_render::parser_panel,
+ parser_render::navigate_panel,
ranking::{top_bottom, RankedItem},
reducer::GroupState,
state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
let empty = GroupState::new();
let group = groups.get(&scope).unwrap_or(&empty);
- let empty_action = ParserAction::suggest(String::new(), None);
let body = html! {
h1 { "sorter2" }
- (parser_panel("", &empty_action))
+ (navigate_panel("", None))
(vote_panel(&scope))
(ranking_panel(&scope, group))
};
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ 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;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
-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<String>)> {
- 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<String> {
- match self {
- EdgePattern::PrefixOf(target) => {
- if target.starts_with(partial) && partial != *target {
- Some(target.to_string())
- } else {
- None
- }
- }
- _ => None,
- }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+ let q = query.trim();
+ if q.is_empty() {
+ return Err("Paste a Reddit URL or r/subreddit path".into());
}
-}
-/// 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 (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
- #[allow(dead_code)]
- id: NodeId,
- edges: Vec<Edge>,
- handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
- nodes: HashMap<NodeId, Node>,
- root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
- nodes: HashMap<NodeId, Node>,
- current_node: Option<NodeId>,
- root: NodeId,
-}
-
-impl GraphBuilder {
- pub fn new() -> Self {
- let mut nodes = HashMap::new();
- nodes.insert(
- "root",
- Node {
- id: "root",
- edges: Vec::new(),
- handler: None,
- },
- );
-
- GraphBuilder {
- nodes,
- current_node: Some("root"),
- root: "root",
- }
+ if let Some(sub) = subreddit_after_prefix(q, "r/") {
+ return Ok(sub);
}
- /// Select a node to add edges to
- pub fn at(mut self, node_id: NodeId) -> Self {
- self.nodes.entry(node_id).or_insert_with(|| Node {
- id: node_id,
- edges: Vec::new(),
- handler: None,
- });
- self.current_node = Some(node_id);
- self
+ if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+ return Ok(sub);
}
-
- /// 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");
-
- self.nodes.entry(target).or_insert_with(|| Node {
- id: target,
- edges: Vec::new(),
- handler: None,
- });
-
- if let Some(node) = self.nodes.get_mut(current) {
- node.edges.push(Edge {
- pattern,
- target,
- description: desc,
- });
- }
- self
- }
-
- /// Set handler for current node
- pub fn handler<F>(mut self, handler: F) -> Self
- where
- F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
- {
- let current = self.current_node.expect("No current node selected");
- if let Some(node) = self.nodes.get_mut(current) {
- node.handler = Some(Box::new(handler));
- }
- self
- }
-
- /// Build the final graph
- pub fn build(self) -> Graph {
- Graph {
- nodes: self.nodes,
- root: self.root,
- }
- }
+ Err("Could not find a subreddit in that URL".into())
}
-// --- 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");
-
- // If we've consumed all input, check for handler or suggestions
- if state.cursor >= state.input.len() {
- if let Some(handler) = &node.han
… preview truncated; 92,533 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.