Side A merely adds standalone, disconnected files (Dockerfile, deps.edn, event_log.rs, views.rs) that appear orphaned—not wired into any build or module tree, so their lasting value is questionable. Side B, despite including a large deleted legacy file, actually integrates a new parser module into the live server (lib.rs, ui_html.rs, html/mod.rs), adds a parser_action/parser_render abstraction, wires up JS for tab-completion, and adds real integration/smoke tests validating the new panel end-to-end.
constitution · epochs · watch · epoch 3
comparison
c_16438843de8f (tommy-mor) vs c_b3cb2762569d (tommy-mor)
download prompt · raw event · cmp_a4013372d8efaa
council reasoning
Side B integrates the full Reddit path parser (moved from legacy, plus new ParserAction/parser_render modules, /ui ParseQuery wiring, home-panel markup, debounced JS tab/completion UX, and integration tests), delivering the project's core interactive navigation feature as lasting design. Side A only seeds ops/scaffold files (Dockerfile, fly.toml, deps.edn) plus smaller standalone modules (event_log.rs, views.rs) with less product surface.
sides
A — c_16438843de8f (tommy-mor)
message
[4cd0d15d] more seed
diff preview
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# time 0.3.47+ requires Rust 1.88 (edition 2024)
+FROM rust:1.88-slim as builder
+
+WORKDIR /build
+
+RUN apt-get update && \
+ apt-get install -y pkg-config libssl-dev && \
+ rm -rf /var/lib/apt/lists/*
+
+# Copy source and build. (Keep it simple to avoid remote build cache oddities.)
+COPY . .
+RUN cargo build --release --package slugsocial-server
+
+FROM debian:bookworm-slim
+
+RUN apt-get update && \
+ apt-get install -y ca-certificates && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server
+
+# Create data directory for persistent volume
+RUN mkdir -p /data
+
+ENV SLUG_DATA_DIR=/data
+ENV SLUG_EVENT_LOG=/data/events.jsonl
+ENV PORT=8080
+
+EXPOSE 8080
+
+CMD ["/app/slugsocial-server"]
+
diff --git a/deps.edn b/deps.edn
new file mode 100644
index 0000000000000000000000000000000000000000..0bf892d44f491cb2313e01ae8a942c3097c52948
--- /dev/null
+++ b/deps.edn
@@ -0,0 +1,10 @@
+{:paths ["." "test"]
+ :deps {cheshire/cheshire {:mvn/version "5.13.0"}
+ http-kit/http-kit {:mvn/version "2.8.0"}
+ babashka/fs {:mvn/version "0.5.32"}
+ babashka/process {:mvn/version "0.6.25"}
+ com.blockether/spel {:mvn/version "0.7.11"}}
+ :aliases
+ {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
+ lambdaisland/kaocha-junit-xml {:mvn/version "1.17.101"}}
+ :main-opts ["-m" "kaocha.runner"]}}}
diff --git a/event_log.rs b/event_log.rs
new file mode 100644
index 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e
--- /dev/null
+++ b/event_log.rs
@@ -0,0 +1,83 @@
+use std::path::{Path, PathBuf};
+
+use tokio::{
+ fs::{self, OpenOptions},
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+};
+
+use crate::events::Event;
+
+#[derive(Debug, thiserror::Error)]
+pub enum EventLogError {
+ #[error("io error: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("json error: {0}")]
+ Json(#[from] serde_json::Error),
+}
+
+#[derive(Debug, Clone)]
+pub struct EventLog {
+ path: PathBuf,
+}
+
+impl EventLog {
+ pub fn new(path: impl Into<PathBuf>) -> Self {
+ Self { path: path.into() }
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {
+ if let Some(parent) = self.path.parent() {
+ fs::create_dir_all(parent).await?;
+ }
+ Ok(())
+ }
+
+ pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {
+ self.ensure_parent_dir().await?;
+ let mut f: tokio::fs::File = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&self.path)
+ .await?;
+
+ let mut line = serde_json::to_string(event)?;
+ line.push('\n');
+ f.write_all(line.as_bytes()).await?;
+ f.flush().await?;
+ Ok(())
+ }
+
+ /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.
+ pub async fn load_all(&self) -> Result<(Vec<Event>, Vec<(usize, String)>), EventLogError> {
+ if !fs::try_exists(&self.path).await? {
+ return Ok((vec![], vec![]));
+ }
+
+ let f = fs::File::open(&self.path).await?;
+ let mut reader = BufReader::new(f).lines();
+
+ let mut events = Vec::new();
+ let mut bad_lines = Vec::new();
+
+ let mut line_no: usize = 0;
+ while let Some(line) = reader.next_line().await? {
+ line_no += 1;
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ match serde_json::from_str::<Event>(trimmed) {
+ Ok(ev) => events.push(ev),
+ Err(_) => bad_lines.push((line_no, line)),
+ }
+ }
+
+ Ok((events, bad_lines))
+ }
+}
+
+
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000000000000000000000000000000000000..bbb9345e527452db1d87a549213645c195eae5fc
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,42 @@
+app = "slugsocial"
+primary_region = "iad"
+
+[build]
+ dockerfile = "Dockerfile"
+
+[env]
+ SLUG_DATA_DIR = "/data"
+ SLUG_EVENT_LOG = "/data/events.jsonl"
+ PORT = "8080"
+
+[[services]]
+ internal_port = 8080
+ protocol = "tcp"
+
+ [[services.ports]]
+ port = 80
+ handlers = ["http"]
+ force_https = true
+
+ [[services.ports]]
+ port = 443
+ handlers = ["tls", "http"]
+
+ [services.concurrency]
+ type = "connections"
+ hard_limit = 1000
+ soft_limit = 500
+
+ [[services.http_checks]]
+ interval = "10s"
+ timeout = "2s"
+ grace_period = "5s"
+ method = "GET"
+ path = "/healthz"
+ protocol = "http"
+ tls_skip_verify = false
+
+[[mounts]]
+ source = "slugsocial_data"
+ destination = "/data"
+
diff --git a/views.rs b/views.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813
--- /dev/null
+++ b/views.rs
@@ -0,0 +1,63 @@
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+};
+use tokio::sync::mpsc;
+
+type CountMap = Arc<Mutex<HashMap<String, u64>>>;
+
+#[derive(Clone)]
+pub struct ViewStore {
+ counts: CountMap,
+ flush_tx: mpsc::Sender<()>,
+}
+
+impl ViewStore {
+ pub fn new(json_path: &str) -> Self {
+ // Load existing counts from disk on startup (best-effort)
+ let initial: HashMap<String, u64> = std::fs::read_to_string(json_path)
+ .ok()
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .unwrap_or_default();
+
+ let counts: CountMap = Arc::new(Mutex::new(initial));
+ let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
+ let path = json_path.to_string();
+
+ let counts_for_writer = counts.clone();
+ tokio::spawn(async move {
+ while flush_rx.recv().await.is_some() {
+ while flush_rx.try_recv().is_ok() {}
+
+ let snapshot: HashMap<String, u64> = {
+ counts_for_writer.lock().unwrap().clone()
+ };
+
+ let path = path.clone();
+ let _ = tokio::task::spawn_blocking(move || {
+ if let Ok(json) = serde_json::to_string(&snapshot) {
+ let tmp = format!("{path}.tmp");
+ if std::fs::write(&tmp, &json).is_ok() {
+ let _ = std::fs::rename(&tmp, &path);
+ }
+ }
+ })
+ .await;
+ }
+ });
+
+ Self { counts, flush_tx }
+ }
+
+ pub fn increment(&self, path: String) {
+ {
+ let mut map = self.counts.lock().unwrap();
+ *map.entry(path).or_insert(0) += 1;
+ }
+ let _ = self.flush_tx.try_send(());
+ }
+
+ pub fn get_views(&self, path: &str) -> u64 {
+ self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
+ }
+}
B — c_b3cb2762569d (tommy-mor)
message
[604a14ad] nice
diff preview
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<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,
- }
- }
-}
-
-/// 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<dyn Fn(&str, &str, &HashMap<String, String>) -> UIAction>;
-
-/// Node in the graph
-pub struct Node {
- #[allow(dead_code)]
- id: NodeId,
- edges: Vec<Edge>,
- handler: Option<Handler>,
-}
-
-/// The composable parser graph
-pub struct Graph {
- nodes: HashMap<NodeId, Rc<RefCell<Node>>>,
- root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
- nodes: HashMap<NodeId, Rc<RefCell<Node>>>,
- current_node: Option<NodeId>,
- 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<F>(self, handler: F) -> Self
- where
- F: Fn(&str, &str, &HashMap<String, String>) -> 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) => {
-
… preview truncated; 152,463 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.