Commit A delivers a coherent, project-wide architectural change with clear functional impact. It removes persistent storage of Reddit payloads from the event log and entity store, introduces ephemeral projection caching with TTL-based eviction, simplifies replay by logging only structural events, updates the journal and projection pipeline, adjusts storage schema versions, and adds or updates tests to validate the new behavior. This is a significant redesign touching many core subsystems while preserving consistency. Commit B is very large in raw size, but much of it appears to be an initial dump of files, design notes, and even pasted terminal output rather than a polished, integrated feature. It mixes utility scripts, planning documents, large parser and reducer implementations, and accidental artifacts. While it contains substantial code, the commit message ('init seed') and the presence of copied terminal sessions reduce its effective contribution as a clean, production-ready change. Overall, A represents the more impactful and deliberate contribution.
constitution · epochs · watch · epoch 3
c_5cd3e5917d2f (tommy-mor) vs c_f6d0fed9bf9a (tommy-mor)
download prompt · raw event · cmp_0dfcddb753e5a2
council reasoning
Commit A is a substantial architectural change: it removes the persistent entity store and shifts Reddit content to an ephemeral, TTL-based projection model. This touches many core subsystems (events, journal, projection, storage schema, reducer, state management, Reddit ingestion flow), introduces new eviction logic, simplifies event semantics, and updates tests accordingly. It meaningfully changes system behavior, data lifecycle, and compliance with external API constraints. In contrast, Commit B is an initial seed adding assorted scripts, experimental modules, and large standalone components (parser, ranking, etc.), but without clear integration into the existing system. While large in size, B is more of a scaffold or dump of new files, whereas A delivers a cohesive, impactful refactor to the live architecture.
Commit A is a large, architectural change that removes the EntityStore and EntityImported event, redesigns Reddit ingestion to keep content ephemeral, updates projection logic, storage schema, eviction background task, tests, and multiple modules. It meaningfully alters data modeling, persistence guarantees, and replay semantics across the server. Commit B is an initial seed adding assorted new files (scripts, parser, ranking, reducer, UI actions, notes), but as presented it’s largely a bulk code drop without clear integration changes in this diff context. The systemic impact and careful refactor in A outweigh B’s scaffold-style addition.
sides
A — c_5cd3e5917d2f (tommy-mor)
message
[1d14ff09] Ephemeral Reddit content; log structure only (#46) * Keep Reddit content ephemeral; log structure only Remove EntityImported and EntityStore. Reddit fetches write display content directly to the projection with a fetched_at timestamp, while the event log records NodeEnsured for discovered identities only. A background task evicts cached display content after 48 hours. Votes, tree structure, and ItemIds remain in the log and projection. Co-authored-by: tommy <thmorriss@gmail.com> * Fix reddit import test assertions and Clojure syntax Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs
index 4762d0d23bc8e2df439afe09a3235ba7f72bd486..3d988416ad36b27a7d3dc84280cfdbdcafa43e69 100644
--- a/server/src/bin/storage_bench.rs
+++ b/server/src/bin/storage_bench.rs
@@ -6,8 +6,8 @@ use std::{
};
use sorter2_server::{
- entity_store::EntityStore, event_log::EventLog, events::Event, journal::JournalClient,
- projection_apply, projection_store::ProjectionStore,
+ event_log::EventLog, events::Event, journal::JournalClient, projection_apply,
+ projection_store::ProjectionStore,
};
#[tokio::main]
@@ -18,12 +18,10 @@ async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let data_dir = opts.data_dir.to_string_lossy().into_owned();
let event_log = Arc::new(EventLog::new(format!("{data_dir}/events.jsonl")));
let db = durable::Db::open(opts.data_dir.join("store"))?;
- let entity_store = EntityStore::from_db(&db)?;
let projection_store = ProjectionStore::from_db(&db)?;
let journal = JournalClient::spawn(
event_log.clone(),
- entity_store.clone(),
projection_store.clone(),
event_log.last_sequence().await? + 1,
);
@@ -46,12 +44,9 @@ async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
drop(journal);
let rebuild_start = Instant::now();
- entity_store.reset()?;
projection_store.reset()?;
let rebuild = event_log
- .replay(|record| {
- projection_apply::apply_records(&projection_store, &entity_store, &[record])
- })
+ .replay(|record| projection_apply::apply_records(&projection_store, &[record]))
.await?;
let rebuild_elapsed = rebuild_start.elapsed();
diff --git a/server/src/entity_store.rs b/server/src/entity_store.rs
deleted file mode 100644
index d29f39aecca6f12cdcf263cf77c3654eb4ee6cfa..0000000000000000000000000000000000000000
--- a/server/src/entity_store.rs
+++ /dev/null
@@ -1,134 +0,0 @@
-//! Off-heap storage for full entity payloads (Reddit API JSON).
-//!
-//! Derived [`crate::reducer::EntityData`] is stored on the node; the raw JSON
-//! lives here, in the shared durable [`Store`] schema.
-
-use std::path::Path;
-
-use durable::{Batch, Db, Durability};
-use serde_json::Value;
-
-use crate::{
- path_types::ItemId,
- storage_dto::{decode_entity_payload, encode_entity_payload},
- storage_schema::{Store, StoreFields},
-};
-
-const ENTITY_SCHEMA_KEY: &str = "schema_version";
-const ENTITY_SCHEMA_VERSION: u64 = 2;
-
-#[derive(Debug, thiserror::Error)]
-pub enum EntityStoreError {
- #[error("durable error: {0}")]
- Durable(#[from] durable::Error),
- #[error("json error: {0}")]
- Json(#[from] serde_json::Error),
- #[error("storage decode error: {0}")]
- Storage(String),
- #[error("io error: {0}")]
- Io(#[from] std::io::Error),
-}
-
-/// Disk-backed map of entity id → raw JSON payload.
-#[derive(Clone)]
-pub struct EntityStore {
- db: Db,
-}
-
-impl EntityStore {
- /// Open (or create) the entity database under `dir`.
- pub fn open(dir: &Path) -> Result<Self, EntityStoreError> {
- std::fs::create_dir_all(dir)?;
- let db = Db::open(dir)?;
- Self::from_db(&db)
- }
-
- /// Create an entity store backed by an already-open database.
- pub fn from_db(db: &Db) -> Result<Self, EntityStoreError> {
- let store = Self { db: db.clone() };
- let version = Store::root()
- .entity_meta()
- .key(&ENTITY_SCHEMA_KEY.to_string())
- .get(db)?;
- if version != Some(ENTITY_SCHEMA_VERSION) {
- store.reset()?;
- }
- Ok(store)
- }
-
- /// Clear rebuildable entity payloads and reset storage schema metadata.
- pub fn reset(&self) -> Result<(), EntityStoreError> {
- let root = Store::root();
- self.db.apply(
- &[root.entities().clear(), root.entity_meta().clear()],
- Durability::SyncWal,
- )?;
- self.db.run(
- root.entity_meta()
- .key(&ENTITY_SCHEMA_KEY.to_string())
- .set(&ENTITY_SCHEMA_VERSION),
- Durability::SyncWal,
- )?;
- Ok(())
- }
-
- /// Persist a payload for `id` (overwrites any existing entry).
- pub fn put(&self, id: &ItemId, payload: &Value) -> Result<(), EntityStoreError> {
- self.db.run(
- Store::root()
- .entities()
- .key(&id.as_str().to_string())
- .set(&encode_entity_payload(payload)),
- Durability::SyncWal,
- )?;
- Ok(())
- }
-
- /// Add a payload write to the caller's batch.
- pub fn put_in_batch(
- &self,
- batch: &mut Batch,
- id: &ItemId,
- payload: &Value,
- ) -> Result<(), EntityStoreError> {
- batch.write(
- Store::root()
- .entities()
- .key(&id.as_str().to_string())
- .set(&encode_entity_payload(payload)),
- );
- Ok(())
- }
-
- /// Load a stored payload, if present.
- pub fn get(&self, id: &ItemId) -> Result<Option<Value>, EntityStoreError> {
- match Store::root()
- .entities()
- .key(&id.as_str().to_string())
- .get(&self.db)?
- {
- Some(record) => decode_entity_payload(record)
- .map(Some)
- .map_err(EntityStoreError::Storage),
- None => Ok(None),
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use serde_json::json;
-
- #[test]
- fn round_trip_payload() {
- let tmp = tempfile::tempdir().unwrap();
- let store = EntityStore::open(tmp.path()).unwrap();
- let id = ItemId::from_url("https://reddit.com/r/rust").unwrap();
- let payload = json!({"kind": "t5", "data": {"display_name": "rust"}});
-
- store.put(&id, &payload).unwrap();
- let loaded = store.get(&id).unwrap().unwrap();
- assert_eq!(loaded, payload);
- }
-}
diff --git a/server/src/events.rs b/server/src/events.rs
index a3d88e285c645a96d33e6f0aed1b487b843aceb8..d76c3bb4277216b0d39c9422ba7a50db10a95e05 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize};
-use serde_json::Value;
/// Schema version for JSONL log records. Bump when event semantics change.
pub const CURRENT_LOG_SCHEMA: u32 = 1;
@@ -30,7 +29,7 @@ pub type ViewRecord = LogRecord<ViewEvent>;
/// Wall-clock timestamp carried on the log envelope for domain events.
pub fn event_timestamp(event: &Event) -> i64 {
match event {
- Event::VoteRecorded { ts, .. } | Event::EntityImported { ts, .. } => *ts,
+ Event::VoteRecorded { ts, .. } => *ts,
Event::NodeEnsured { .. } => crate::fetch::now_ms(),
}
}
@@ -57,6 +56,4 @@ pub enum Event {
},
/// Register a node path in the fractal tree (no external fetch).
NodeEnsured { id: String },
- /// Full upstream API payload for a node (domain-specific view derived at replay/render time).
- EntityImported { id: String, ts: i64, payload: Value },
}
diff --git a/server/src/journal.rs b/server/src/journal.rs
index 50bc89f976edb82b7b0e49e954a8eccbbe82bf87..d50023aca7c3e74068de988449b11baee32d1ecf 100644
--- a/server/src/journal.rs
+++ b/server/src/journal.rs
@@ -5,7 +5,6 @@ use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
use crate::{
- entity_store::EntityStore,
event_log::EventLog,
events::{event_timestamp, Event, EventRecord},
projection_apply,
@@ -25,7 +24,6 @@ pub struct JournalClient {
impl JournalClient {
pub fn spawn(
event_log: Arc<EventLog>,
- entity_store: EntityStore,
projection_store: ProjectionStore,
next_seq: u64,
) -> Self {
@@ -33,7 +31,6 @@ impl JournalClient {
tokio::spawn(journal_worker(
rx,
event_log,
- entity_store,
projection_store,
next_seq,
));
@@ -62,7 +59,6 @@ impl JournalClient {
async fn journal_worker(
mut rx: mpsc::Receiver<JournalCommand>,
event_log: Arc<EventLog>,
- entity_store: EntityStore,
projection_store: ProjectionStore,
mut next_seq: u64,
) {
@@ -75,7 +71,6 @@ async fn journal_worker(
let result = append_and_project_batch(
&event_log,
&projection_store,
- &entity_store,
&mut next_seq,
&batch,
)
@@ -99,7 +94,6 @@ async fn journal_worker(
async fn append_and_project_batch(
event_log: &EventLog,
projection_store: &ProjectionStore,
- entity_store: &EntityStore,
next_seq: &mut u64,
commands: &[JournalCommand],
) -> Result<(), String> {
@@ -117,7 +111,7 @@ async fn append_and_project_batch(
.await
.map_err(|e| e.to_string())?;
*next_seq = seq;
- projection_apply::apply_records(projection_store, entity_store, &records)
+ projection_apply::apply_records(projection_store, &records)
.map_err(|e| format!("projection apply failed after durable append: {e}"))
}
@@ -132,10 +126,9 @@ mod tests {
let log_path = tmp.path().join("events.jsonl");
let event_log = Arc::new(EventLog::new(log_path));
let db = durable::Db::open(tmp.path().join("store")).unwrap();
- let entity_store = EntityStore::from_db(&db).unwrap();
let projection_store = ProjectionStore::from_db(&db).unwrap();
- let journal = JournalClient::spawn(event_log, entity_store, projection_store.clone(), 1);
+ let journal = JournalClient::spawn(event_log, projection_store.clone(), 1);
let j1 = journal.clone();
let j2 = journal.clone();
@@ -177,11 +170,9 @@ mod tests {
.unwrap();
let db = durable::Db::open(tmp.path().join("store")).unwrap();
- let entity_store = EntityStore::from_db(&db).unwrap();
let projection_store = ProjectionStore::from_db(&db).unwrap();
projection_apply::apply_records(
&projection_store,
- &entity_store,
&[EventRecord::new(
1,
1,
@@ -196,7 +187,6 @@ mod tests {
let journal = JournalClient::spawn(
event_log.clone(),
- entity_store,
projection_store.clone(),
next_seq,
);
@@ -219,10 +209,9 @@ mod tests {
let log_path = tmp.path().join("events.jsonl");
let event_log = Arc::new(EventLog::new(log_path));
let db = durable::Db::open(tmp.path().join("store")).unwrap();
- let entity_store = EntityStore::from_db(&db).unwrap();
let projection_store = ProjectionStore::from_db(&db).unwrap();
let journal =
- JournalClient::spawn(event_log.clone(), entity_store, projection_store.clone(), 1);
+ JournalClient::spawn(event_log.clone(), projection_store.clone(), 1);
journal
.append_many(vec![
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 5c02c8e704e4664453bad75d819df8a067668176..3dfc7c8acb8ed61bb73ade63e72768e402042cc5 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -1,5 +1,4 @@
pub mod api;
-pub mod entity_store;
pub mod event_log;
pub mod events;
pub mod fetch;
diff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs
index 5644557a41b3e9497c7421b444155ae629fa79f1..9c8990a8af927f35d3344c8d0872a516aba56b86 100644
--- a/server/src/projection_apply.rs
+++ b/server/src/projection_apply.rs
@@ -1,22 +1,20 @@
//! Apply event-log records to the durable projection as precise point updates.
//!
//! Each batch of records lowers to reified durable writes (edge merges, child
-//! links, voted-pair flags, recent-vote pushes, entity payloads) plus a cursor
-//! advance, all committed in one atomic `DisableWal` batch. The cursor moving in
-//! the same batch as the (non-idempotent) edge merges guarantees e
… preview truncated; 33,427 characters omittedB — c_f6d0fed9bf9a (tommy-mor)
message
[1d9d8ade] init seed
diff preview
diff --git a/TEST.sh b/TEST.sh
new file mode 100755
index 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39
--- /dev/null
+++ b/TEST.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cargo test --all
+./scripts/clj-test.sh
diff --git a/bundle.js b/bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265
--- /dev/null
+++ b/bundle.js
@@ -0,0 +1,52 @@
+/**
+ * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.
+ */
+(function () {
+ function evalJs(js) {
+ if (js && String(js).trim()) {
+ eval(js);
+ }
+ }
+
+ // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)
+
+ function initSlugUi() {
+ // POST forms → eval response (except theme + full-navigation forms)
+ document.addEventListener('submit', async function (e) {
+ var f = e.target;
+ if (!f || f.tagName !== 'FORM') return;
+ if ((f.method || 'get').toLowerCase() !== 'post') return;
+ if (f.id === 'slug-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());
+ });
+
+ // SSE: server-pushed JS
+ function connectSSE() {
+ var ssePath = window.location.pathname + window.location.search;
+ var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));
+ es.onmessage = function (e) {
+ evalJs(e.data);
+ };
+ es.onerror = function () {
+ es.close();
+ setTimeout(connectSSE, 3000);
+ };
+ }
+ connectSSE();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initSlugUi);
+ } else {
+ initSlugUi();
+ }
+})();
+
diff --git a/clj-test.sh b/clj-test.sh
new file mode 100755
index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952
--- /dev/null
+++ b/clj-test.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mkdir -p target
+exec clojure -M:kaocha
diff --git a/forms.rs b/forms.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a
--- /dev/null
+++ b/forms.rs
@@ -0,0 +1,145 @@
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs
+//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from
+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**
+//! deserializing into a typed struct.
+//!
+//! # Wire format
+//!
+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty
+//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string
+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not
+//! bespoke encodings.
+//!
+//! # Power vs flat hidden fields
+//!
+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),
+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,
+//! arrays, and optional fields without inventing a new naming scheme each time.
+//!
+//! # Security
+//!
+//! Substitution runs **before** `serde` into your command type. It does not fix
+//! authorization: if the client can replace the hidden `__rpc__` value, they can
+//! change the command shape unless you validate (signed blob, server-side session
+//! context, or treat the blob as hints only). Same threat model as any hidden field.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String> {
+ serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+ match val {
+ Value::Object(map) => {
+ if map.len() == 1 {
+ if let Some(Value::String(field_name)) = map.get("$form") {
+ let submitted = form_data
+ .get(field_name.as_str())
+ .map(|s| s.as_str())
+ .unwrap_or("");
+ *val = Value::String(submitted.to_string());
+ return;
+ }
+ }
+ for v in map.values_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ Value::Array(arr) => {
+ for v in arr.iter_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.
+pub fn fill_template_from_form(
+ template_json: &str,
+ form_data: &HashMap<String, String>,
+) -> Result<Value, serde_json::Error> {
+ let mut v: Value = serde_json::from_str(template_json)?;
+ substitute_form_vars(&mut v, form_data);
+ Ok(v)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde::Deserialize;
+
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
+ struct Demo {
+ room: String,
+ thread_tag: String,
+ nested: Nested,
+ }
+
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
+ struct Nested {
+ text: String,
+ }
+
+ #[test]
+ fn holes_become_strings() {
+ let json = r#"{
+ "room": "public",
+ "thread_tag": {"$form": "tag"},
+ "nested": {"text": {"$form": "body"}}
+ }"#;
+ let mut form = HashMap::new();
+ form.insert("tag".into(), "foo".into());
+ form.insert("body".into(), "hello\nworld".into());
+
+ let v = fill_template_from_form(json, &form).unwrap();
+ let d: Demo = serde_json::from_value(v).unwrap();
+ assert_eq!(
+ d,
+ Demo {
+ room: "public".into(),
+ thread_tag: "foo".into(),
+ nested: Nested {
+ text: "hello\nworld".into(),
+ },
+ }
+ );
+ }
+
+ #[test]
+ fn missing_form_key_is_empty_string() {
+ let json = r#"{"x": {"$form": "nope"}}"#;
+ let mut form = HashMap::new();
+ form.insert("other".into(), "y".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["x"], "");
+ }
+
+ #[test]
+ fn array_of_holes() {
+ let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#;
+ let mut form = HashMap::new();
+ form.insert("a".into(), "1".into());
+ form.insert("b".into(), "2".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["items"], serde_json::json!(["1", "2"]));
+ }
+
+ #[test]
+ fn template_json_compact_escapes_and_single_line() {
+ let s = template_json_compact(&serde_json::json!({
+ "x": "quote\"and\nnewline"
+ }))
+ .unwrap();
+ assert!(!s.contains('\n'));
+ assert!(s.contains("\\\"") || s.contains("\\n"));
+ }
+}
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒
+
diff --git a/gameifying.tdsl b/gameifying.tdsl
new file mode 100644
index 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15
--- /dev/null
+++ b/gameifying.tdsl
@@ -0,0 +1,2 @@
+consider gating certain views (top all time?) by a 10 day usage streak.. or something like that.
+or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive.
diff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl
new file mode 100644
index 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a
--- /dev/null
+++ b/pagerank_streaming.tdsl
@@ -0,0 +1,11 @@
+eventually i want to have ranking histories.
+like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk.
+there are too many query combinations to precomupte the ranking histories
+(arbitrary user filters, and tag overlap combinations maybe),
+so we're just going to have to calculate them all on demand.
+computers are fast, its okay. for each vote, we need to calculate rank centrality again.
+i was thinking, for n votes we calculate the rank centrality,
+and get node weights. we then output that data to the client (over websocket, or testable barrier).
+then we calculate n+1 votes, _but we keep the node weights in memory_
+so the rank centrality process converges faster.
+this also has the side effect of making the ranking stream in satisfyingly as you load the page.
diff --git a/parser.rs b/parser.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349
--- /dev/null
+++ b/parser.rs
@@ -0,0 +1,1808 @@
+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
+ }
+ }
+ }
+ }
+
+ /// G
… preview truncated; 148,978 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.