Side B implements a real DSL syntax change (explanation-first votes) with careful parser rework, backward-compat rejection logic, and updates across docs, fixtures, server tests, and browser tests, showing broad, consistent propagation of a design decision. Side A adds a useful but narrow RPC/CLI feature (RoomList) with good test coverage, but it's a smaller, more contained addition compared to B's cross-cutting language/parser change that touches core DSL semantics used throughout the system.
constitution · epochs · watch · epoch 3
c_552f408ae0da (tommy-mor) vs c_2f5d9e0370f8 (tommy-mor)
download prompt · raw event · cmp_ba1933556832ef
council reasoning
B redesigns the core sorter DSL so vote explanations lead and verdicts trail, with real parser/UI changes (dsl.rs, ui_html vote formatting, editor placeholder) that redefine how judgments are authored. A adds a useful RoomList RPC/CLI path with solid isolation tests, but it is incremental surface API work versus B’s lasting language-level design shift (even though much of B’s diff is fixture propagation).
Side A adds a new end-to-end capability: a RoomList RPC, CLI `room list` subcommand, shared request/response types, server implementation that filters rooms by authenticated principal's grants, and integration tests verifying per-user isolation and grant behavior. Side B primarily changes DSL syntax from trailing to leading vote explanations, updating the parser, UI generation, documentation, and many fixtures/tests to match; while substantial, it is largely a syntax migration rather than introducing comparable new functionality.
sides
A — c_552f408ae0da (tommy-mor)
message
[9acdf18a] feat: add RoomList RPC command and CLI room list subcommand Returns all rooms the authenticated principal has a grant in. Includes integration tests proving per-user isolation: users only see rooms they have been explicitly granted, not all rooms in the system. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/bb.edn b/bb.edn
index f7c53eb2d5def514a8f2c480ac420416205db14e..8dc6a5be7321de198cbb5939842a33b8c5d52625 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,16 +47,18 @@
"RUST_LOG" "info"})})))}
test
- {:doc "Full test suite: integration + auth + grants + invites"
+ {:doc "Full test suite: integration + auth + grants + invites + room-list"
:requires ([test.integration :as integration]
[test.auth :as auth]
[test.grants :as grants]
- [test.invites :as invites])
+ [test.invites :as invites]
+ [test.room-list :as room-list])
:task (do
(integration/integration)
(auth/auth-test)
(grants/grants-test)
- (invites/invites-test))}
+ (invites/invites-test)
+ (room-list/room-list-test))}
walkthrough-fixture
{:doc "Run local server + mock OAuth + seeded walkthrough data for manual browser demos"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 69492cb5417a0a19c38f8cacbeadd103bd905b6f..b008cf377bb360aaae666d2dfd4b5e13dedb204a 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -219,6 +219,12 @@ enum RoomCmd {
#[arg(long)]
json: bool,
},
+ /// List rooms the authenticated user has access to
+ List {
+ /// Output as JSON for agent parsing
+ #[arg(long)]
+ json: bool,
+ },
}
#[derive(Subcommand, Debug)]
@@ -1319,6 +1325,38 @@ async fn main() -> Result<()> {
_ => return Err(anyhow!("unexpected RPC result")),
}
}
+ RoomCmd::List { json } => {
+ let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::RoomList],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::RoomList(resp) => {
+ if json {
+ println!("{}", serde_json::to_string_pretty(&resp)?);
+ } else {
+ if resp.rooms.is_empty() {
+ println!("no rooms");
+ } else {
+ for room in &resp.rooms {
+ println!("{room}");
+ }
+ }
+ }
+ }
+ _ => return Err(anyhow!("unexpected RPC result")),
+ }
+ }
},
Command::Healthz { json } => {
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ed4800e7cbdeaf04e72192c191e71354e603c1fe..f1ee6d35b95a1a28490823e907b8f4dc5c091b94 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1345,6 +1345,25 @@ pub async fn handle_rpc_batch(
}
}
}
+ RpcCommand::RoomList => {
+ let principal = {
+ let reduced = state.reduced.read().await;
+ verify_bearer_principal(&headers, &*reduced)
+ };
+ match principal {
+ Err((_, m)) => line_err(m, None),
+ Ok(principal) => {
+ let reduced = state.reduced.read().await;
+ let rooms: Vec<String> = reduced
+ .grants
+ .iter()
+ .filter(|(_, members)| members.contains_key(&principal))
+ .map(|(room, _)| room.clone())
+ .collect();
+ line_ok(RpcResult::RoomList(RoomListResponse { rooms }))
+ }
+ }
+ }
RpcCommand::RoomRevoke {
room,
username,
diff --git a/test/room_list.clj b/test/room_list.clj
new file mode 100644
index 0000000000000000000000000000000000000000..a089a722ac8d5f822f47d5511a46f671d61d46cf
--- /dev/null
+++ b/test/room_list.clj
@@ -0,0 +1,158 @@
+(ns test.room-list
+ "Room list integration test: list rooms user has access to via POST /api/v0/rpc.
+
+ Covers:
+ - user with no rooms -> empty list
+ - user with one room -> list contains that room
+ - user with multiple rooms -> list contains all rooms"
+ (:require [babashka.fs :as fs]
+ [cheshire.core :as json]
+ [clojure.set :as set]
+ [test.common :as common]
+ [test.oauth :as oauth]))
+
+(def ^:private counts (atom {:pass 0 :fail 0}))
+
+(defn- assert! [pred msg]
+ (common/test-assert! counts pred msg))
+
+(defn- bearer [token] {"Authorization" (str "Bearer " token)})
+
+(defn- rpc-batch! [base-url token cmds]
+ (let [resp (oauth/http-post-json (str base-url "/api/v0/rpc") cmds :headers (bearer token))]
+ {:status (:status resp)
+ :parsed (json/parse-string (:body resp) false)}))
+
+(defn- rpc-line-ok? [parsed]
+ (true? (get-in parsed ["results" 0 "ok"])))
+
+(defn- register-user! [base-url session-agent username]
+ (oauth/complete-registration! base-url
+ :agent session-agent
+ :username username
+ :assert! (fn [pred msg] (assert! pred msg))))
+
+(defn room-list-test [& _args]
+ (println "\n━━━ room list integration check ━━━\n")
+ (reset! counts {:pass 0 :fail 0})
+
+ (println "building server binary…")
+ (common/letlocals
+ (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
+ (assert! (zero? (:exit build)) "cargo build succeeds")
+ (bind server-bin "target/release/slugsocial-server")
+
+ (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-room-list-"})))
+ (bind slug-port (common/pick-port))
+ (bind google-port (common/pick-port))
+ (bind base-url (str "http://127.0.0.1:" slug-port))
+ (bind google-url (str "http://127.0.0.1:" google-port))
+
+ (bind !server (atom nil))
+ (bind !google (atom nil))
+
+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))
+ (try
+ (println (str "starting mock google on :" google-port))
+ (reset! !google (oauth/start-mock-google google-port
+ :google-users ["google-user-alice"
+ "google-user-bob"
+ "google-user-carol"]))
+
+ (println (str "starting server on :" slug-port))
+ (reset! !server (common/start-server server-bin server-env))
+ (assert! (common/wait-for-server base-url 10000) "server responds to /healthz")
+
+ (println "\nregistering alice, bob, carol…")
+ (let [alice-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000001:test:local/dev"
+ "alice")
+ bob-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000002:test:local/dev"
+ "bob")
+ carol-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000003:test:local/dev"
+ "carol")
+
+ ;; Alice creates two private rooms
+ _ (println "\nalice creates two rooms…")
+ room-id-1 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-one"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? room-id-1) "alice room-one created")
+ room-id-2 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-two"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? room-id-2) "alice room-two created")
+
+ ;; Carol creates her own room
+ _ (println "carol creates her own room…")
+ carol-room (-> (rpc-batch! base-url carol-token [{"RoomCreate" {"slug" "carol-room"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? carol-room) "carol room created")]
+
+ ;; --- isolation: alice only sees her rooms, not carol's ---
+ (println "\nalice sees her 2 rooms but not carol's…")
+ (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1 room-id-2} rooms)
+ "alice sees exactly her 2 rooms")
+ (assert! (not (contains? rooms carol-room))
+ "alice does NOT see carol's room"))
+
+ ;; --- isolation: carol only sees her room, not alice's ---
+ (println "carol sees only her room…")
+ (let [rooms (-> (rpc-batch! base-url carol-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{carol-room} rooms)
+ "carol sees exactly her own room")
+ (assert! (not (contains? rooms room-id-1))
+ "carol does NOT see alice's room-one")
+ (assert! (not (contains? rooms room-id-2))
+ "carol does NOT see alice's room-two"))
+
+ ;; --- bob sees nothing yet: alice has 3 rooms total but bob is in none ---
+ (println "bob (no grants) sees no rooms despite 3 existing…")
+ (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"]))]
+ (assert! (zero? (count rooms))
+ "bob sees 0 rooms even though 3 exist in the system"))
+
+ ;; --- partial grant: alice grants bob room-one only ---
+ (println "\nalice grants bob view on room-one only…")
+ (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token
+ [{"RoomGrant" {"room" room-id-1
+ "username" "bob"
+ "capabilities" ["view"]}}])))
+ "grant ok")
+
+ ;; bob sees room-one but NOT room-two or carol's room
+ (println "bob sees room-one but not room-two or carol's room…")
+ (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1} rooms)
+ "bob sees exactly room-one")
+ (assert! (not (contains? rooms room-id-2))
+ "bob does NOT see alice's room-two (not granted)")
+ (assert! (not (contains? rooms carol-room))
+ "bob does NOT see carol's room (not granted)"))
+
+ ;; alice's view is unchanged
+ (println "alice's view unchanged after granting bob…")
+ (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1 room-id-2} rooms)
+ "alice still sees exactly her 2 rooms after granting bob")))
+
+ (fin
… preview truncated; 2,078 characters omittedB — c_2f5d9e0370f8 (tommy-mor)
message
[6bda2635] Use title-first items and explanation-first votes (#135) * Require block-first sorter DSL statements Co-authored-by: tommy <thmorriss@gmail.com> * Use title-first items with explanation-first votes Co-authored-by: tommy <thmorriss@gmail.com> * Update garden vote test DSL fixtures Co-authored-by: tommy <thmorriss@gmail.com> * Update browser vote DSL payloads Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/cli/DSL.txt b/cli/DSL.txt
index 18f69ef25f01583fddf9fc90e077bb2c3fa72bb6..c9b12bf0edaf73ad252f0a202b7fe61e311df50a 100644
--- a/cli/DSL.txt
+++ b/cli/DSL.txt
@@ -18,9 +18,20 @@ Blank lines are preserved to maintain paragraph structure.
~/item/a {itembody}
~/python { A high-level scripting language }
-~/item/a > ~/python { Item A is better because of X. }
-~/python 3:1 ~/go { Python's ecosystem is much richer than Go's. }
-https://example.com/lang = ~/go { They are equally good in this context. }
+{
+Item A is better because of X.
+}
+~/item/a > ~/python
+
+{
+Python's ecosystem is much richer than Go's.
+}
+~/python 3:1 ~/go
+
+{
+They are equally good in this context.
+}
+https://example.com/lang = ~/go
```
SYNTAX RULES
@@ -35,7 +46,7 @@ Starts a thread. Tag allows alphanumeric, `-`, `_`, and `/`. Subtitle max 100 ch
~/<local-item-path> { description }
```
Defines an ontology item (garden layer). Paths can be nested (e.g. `~/languages/python`).
-A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}`. Can be adjacent (e.g. `~/arrived{ready}`).
+A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}` and follow the item path.
```sorter
https://example.com/item { description }
@@ -46,7 +57,10 @@ Canonicalization rules for URLs:
- `~/` and `https://slug.social/~/` map to the same local item path.
```sorter
-<item1> <comparison> <item2> { required explanation }
+{
+required explanation
+}
+<item1> <comparison> <item2>
```
Compares two items. The explanation is REQUIRED.
Comparisons:
@@ -65,7 +79,8 @@ When writing bodies or explanations, you can use braces `{}` and code blocks wit
3. Single braces: { ... }
```sorter
-~/code { Here is a block: ```def foo(): return {"a": 1}``` }
+{ Here is a block: ```def foo(): return {"a": 1}``` }
+~/code
```
STYLE
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 7ea1c2649cb4a323b46f0bf389025079a9c9ab43..86accba72ecea547215d947fb6552e0ead25687c 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -83,7 +83,8 @@ Item definitions (attaches a description to an item):
~/thread/item { description }
Comparisons:
- ~/thread/item-a 3:1 ~/thread/item-b { reasoning }
+ { reasoning }
+ ~/thread/item-a 3:1 ~/thread/item-b
Ratio formats:
3:1 left is 3x better than right
@@ -95,8 +96,7 @@ Shorthand:
< means 1:2 (right is better)
= means 1:1 (equal)
-Bodies can attach without whitespace:
- ~/thread/item{Description here}
+Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line.
}
You can write any prose in your posts. These won't be part of the garden but only the thread.
@@ -165,7 +165,8 @@ npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-c
~/languages/python { A high-level language focused on readability. }
~/languages/rust { A systems language focused on safety and performance. }
-~/languages/python 2:1 ~/languages/rust { Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }
+{ Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }
+~/languages/python 2:1 ~/languages/rust
EOF
# See current ranking
diff --git a/ideas/single-thread.md b/ideas/single-thread.md
index 86230fe6119c31045c21dbfcb12311b323c02fa8..0efb7199524cc3b308b1922c03e3a99193e4586c 100644
--- a/ideas/single-thread.md
+++ b/ideas/single-thread.md
@@ -15,7 +15,8 @@ Previously a `.sorter` document could scatter `#tags` throughout:
~/languages/rust {A systems language.}
#tools
~/tools/cargo {Rust's build system.}
-~/languages/rust 2:1 ~/tools/cargo {Rust is more foundational than its tooling.}
+{Rust is more foundational than its tooling.}
+~/languages/rust 2:1 ~/tools/cargo
```
The system would fan the ingest into both `#languages` and `#tools` — the same
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 9f3c1cc21c2ae157c024a447980a97e190c8f066..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -72,16 +72,16 @@ mod tests {
apply_ingest(
&mut reduced,
1,
- "~/t/a {a}\n~/t/b {b}\n~/t/a 2:1 ~/t/b {because}\n",
+ "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 2:1 ~/t/b\n",
);
- let text = "~/t/a 1:1 ~/t/b {equal}\n";
+ let text = "{equal}\n~/t/a 1:1 ~/t/b\n";
validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();
}
#[test]
fn validate_ingest_document_rejects_vote_on_undefined_item() {
let reduced = ReducerState::default();
- let text = "~/t/a {x}\n~/t/b 1:1 ~/t/missing {why}\n";
+ let text = "~/t/a {x}\n{why}\n~/t/b 1:1 ~/t/missing\n";
let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.contains("undefined item"));
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 696e7b3605e2e68aee0351116c494c2958506add..7cbda3876451687aa7a55547fc06bbe86ac9d260 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -222,13 +222,13 @@ async fn dispatch_ui_action(
}
let text = format!(
- "@{}\n{} {}:{} {} {{\n{}\n}}\n",
+ "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
crate::api::auth::WEB_BROWSER_AGENT,
+ exp,
left_id.as_str(),
rl,
rr,
- right_id.as_str(),
- exp
+ right_id.as_str()
);
match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index 7962342bd023b3b5061a684d84d4ab164134b111..def8b497c71567016a522648b9630d7038163e41 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -11,16 +11,21 @@ pub struct Document {
/// A single statement in the DSL (or prose when using `parse_full`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stmt {
- Item { title: String, body: Option<String> },
+ Item {
+ title: String,
+ body: Option<String>,
+ },
Vote {
item1: String,
item2: String,
ratio_left: i32,
ratio_right: i32,
- /// Required non-empty explanation (from trailing `{ ... }`).
+ /// Required non-empty explanation (from leading `{ ... }`).
explanation: String,
},
- Prose { text: String },
+ Prose {
+ text: String,
+ },
}
#[derive(Debug, thiserror::Error)]
@@ -391,71 +396,46 @@ fn parse_comparison_at(s: &str, i: usize) -> Option<((i32, i32), usize)> {
Some(((left, right), j))
}
-fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
- // item: ("~/" | "https://..." | "http://...") item_ref body?
- // vote: same for both operands.
- //
- // Important: body token can be adjacent to the item name (no whitespace),
- // e.g. "~/arrived{...}" -> "~/arrived__BLOCK_x__".
- let s = stripped;
- let bytes = s.as_bytes();
- if bytes.is_empty() {
- return Err(DslError::Parse("missing item statement".to_string()));
+fn parse_block_prefixed_statement(
+ block_token: &str,
+ tail: &str,
+ masker: &BlockMasker,
+) -> Result<Stmt, DslError> {
+ // vote: block item_ref comparison item_ref
+ let s = tail.trim_start();
+ if s.is_empty() {
+ return Err(DslError::Parse(
+ "missing vote statement after leading explanation block".to_string(),
+ ));
}
let (item1, j) =
parse_item_name_at(s, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+ let explanation = masker.extract_body(block_token);
+ let mut i = skip_ws(s, j);
- // Either we have:
- // - immediate/whitespace block token => Item
- // - comparison => Vote
- // - whitespace then block token => Item
- // - whitespace then comparison => Vote
- let i = skip_ws(s, j);
-
- // If next is end or a block token => Item.
if i >= s.len() {
- return Ok(Stmt::Item {
- title: item1,
- body: None,
- });
- }
- if let Some((tok, end)) = parse_block_token_at(s, i) {
- let body = masker.extract_body(&tok);
- let tail = s[end..].trim();
- if !tail.is_empty() {
- return Err(DslError::Parse("extra tokens after item".to_string()));
- }
- return Ok(Stmt::Item {
- title: item1,
- body: Some(body),
- });
+ return Err(DslError::Parse(
+ "leading `{ ... }` blocks are vote explanations; item bodies belong after item paths"
+ .to_string(),
+ ));
}
- // Otherwise parse comparison then "/item2" then REQUIRED body.
- let ((ratio_left, ratio_right), mut k) = parse_comparison_at(s, i)
+ let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)
.ok_or_else(|| DslError::Parse(format!("invalid comparison near: {}", &s[i..])))?;
if ratio_left == 0 && ratio_right == 0 {
return Err(DslError::Parse(
"vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote".to_string(),
));
}
- k = skip_ws(s, k);
- let (item2, mut m) = parse_item_name_at(s, k)
+ i = skip_ws(s, k);
+ let (item2, m) = parse_item_name_at(s, i)
.ok_or_else(|| DslError::Parse("invalid rhs item name".to_string()))?;
- m = skip_ws(s, m);
-
- let Some((tok, end)) = parse_block_token_at(s, m) else {
- return Err(DslError::Parse(
- "missing vote explanation (add a trailing `{ ... }`)".to_string(),
- ));
- };
- let explanation = masker.extract_body(&tok);
+ i = skip_ws(s, m);
if explanation.trim().is_empty() {
return Err(DslError::Parse("empty vote explanation".to_string()));
}
- m = end;
- let tail = s[m..].trim();
+ let tail = s[i..].trim();
if !tail.is_empty() {
return Err(DslError::Parse("extra tokens after vote".to_string()));
}
@@ -469,6 +449,35 @@ fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, Ds
})
}
+fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
+ let (item1, j) =
+ parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+ let i = skip_ws(stripped, j);
+
+ if i >= stripped.len() {
+ return Ok(Stmt::Item {
+ title: item1,
+ body: None,
+ });
+ }
+
+ if let Some((tok, end)) = parse_block_token_at(stripped, i) {
+ let body = masker.extract_body(&tok);
+ let tail = stripped[end..].trim();
+ if !tail.is_empty() {
+ return Err(DslError::Parse("extra tokens after item".to_string()));
+ }
+ return Ok(Stmt::Item {
+ title: item1,
+ body: Some(body),
+ });
+ }
+
+ Err(DslError::Parse(
+ "vote explanations must start with a `{ ... }` block before the comparison".to_string(),
+ ))
+}
+
fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslError> {
let stripped = masked_line.trim_start();
if stripped.is_empty() {
@@ -477,27 +486,28 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslE
let first = stripped.chars().next().unwrap();
match first {
'#' => Err(DslError::Parse("not a DS
… preview truncated; 47,409 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.