mirror of
https://github.com/block/buzz.git
synced 2026-11-10 19:32:00 +00:00
Replaces #8759, which had its head on a fork. This branch also carries
the stars/mutes (#8781) and sort mode (#8784) work that merged into it.
## Summary
Sidebar sections were desktop-only. They live in the encrypted
kind:30078 `channel-sections` preference, and the CLI had no command for
it. An agent that mirrors a team's Slack channels into Buzz could create
the channels but not group them into sections.
This adds `buzz sidebar sections`:
| Command | What it does |
|---|---|
| `list` | `{sections: [{id, name, icon?, order}], assignments:
{channel_id: section_id}}` |
| `create --name <n> [--icon <emoji>]` | Adds a section at the end;
returns `section_id` |
| `update --section <s> [--name] [--icon \| --clear-icon]` | Renames a
section or changes its icon |
| `delete --section <s>` | Deletes a section; its channels go back to
the default list |
| `reorder --section <s>...` | Sets the section order; unlisted sections
keep their order after the listed ones |
| `move --channel <uuid>... --section <s>` | Moves one or more channels
into a section |
| `unassign --channel <uuid>...` | Returns channels to the default list
|
`--section` takes a section id, or a section name when the name is
unique. One `create` and one `move` per section set up a whole sidebar:
```bash
buzz sidebar sections create --name "Engineering" --icon "🛠"
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
```
## Design
- **Wire compatibility.** `sidebar_doc.rs` ports desktop's per-field LWW
register document (`channelSectionsSync.ts`, `sidebarLwwMap.ts`,
`sidebarLaneStore.ts`): decode, shape validation, projection, encode,
and `setRegs` stamping (`v = max(now, newest + 1)`, one shared stamp per
edit, unchanged leaves not rewritten). CLI writes merge with desktop
edits field by field. The CLI also imports the flat (meta-less) format
that mobile writes.
- **One user action is one publish.** Each command reads the head once
and publishes once. A multi-channel `move` therefore lands as one event.
A command that changes nothing publishes nothing and prints `"message":
"unchanged"`.
- **Never overwrite what it cannot read.** If the head fails decryption,
signature verification, or decoding (for example, an unknown `meta.v`),
`list` and every write fail. They do not treat the head as empty. A
write that the relay reports as dominated exits 5 (`Conflict`).
- **Same limits as desktop.** 100 sections, 1,000 assignments, and the
65,535-byte ciphertext cap. Channel IDs are stored in lowercase
hyphenated form, which is the form desktop matches.
- **No relay change.** The relay already stores `channel-sections` as a
generic replaceable kind:30078 event.
## Scope notes
- The commands always edit the **signing identity's own** sidebar,
because the document is NIP-44 encrypted to self. A local agent that
runs with the user's key (for example, `BUZZ_PRIVATE_KEY` set to the
user's nsec) sets up that user's sidebar. A managed agent with its own
key edits only its own sidebar.
- The request also mentioned "an equivalent agent/MCP tool". Agents use
`buzz-cli` for agent-facing operations, so the CLI help and
`crates/buzz-cli/README.md` document the `list` output contract. This PR
adds no separate MCP tool. This PR does not change `desktop/`
(deprecated; see #8840).
- `create` does not dedupe by name, which matches desktop. If a setup
script runs twice, it creates duplicate sections, and later `--section
<name>` lookups then fail as ambiguous. They do not pick one silently.
## Testing
- `cargo test -p buzz-cli`: 513 pass. The sidebar tests are:
- A journey through the production command functions against a fake
relay. The fake relay checks the head filter (`kinds`, `authors`, `#d`,
`limit`). The journey covers list, create, a multi-channel move as one
publish, a no-op repeat, invalid input with no publish, reorder, update,
unassign, delete, and the decrypted event's kind, tags, and document
shape.
- Rows for head selection (newest `created_at`, lower id on a tie,
another author's event ignored), `created_at = head + 1` after a
future-dated head, a mobile flat head, an unreadable head, a tampered
head, the count limit, the ciphertext cap, the NIP-44 plaintext cap, an
empty `--icon`, and a dominated write (exit 5).
- Each guard was mutation-checked: removing it makes at least one test
fail.
- **Desktop interop:** a CLI-written document was fed to desktop's own
`decodeDoc` and `projectSections` (from
`desktop/src/features/sidebar/lib`, run with node). Desktop decoded it
as a meta document, its projection matched the CLI's exactly, and
`canonical(json) === canonical(encodeDoc(tree))`. So desktop treats the
head as settled and does not republish it.
- **Live read:** `buzz sidebar sections list` against a real relay
correctly decrypted and projected a desktop-written sidebar (2 sections,
2 assignments). I ran no live writes.
- `cargo clippy -p buzz-cli --all-targets -D warnings` and `cargo fmt
--check` are clean.
- An agent review ran. It reported no blockers, and its optional
findings were fixed: tests now bind the relay filter and head selection,
a silently dropped head is an error, a no-op `reorder` writes nothing,
the `safe_int` overflow is fixed, channel IDs are canonical, and an
empty `--icon` is rejected.
### Manual test steps
1. `cargo build -p buzz-cli`, then set `BUZZ_PRIVATE_KEY` (your nsec)
and `BUZZ_RELAY_URL`.
2. `buzz sidebar sections list`: you see your current desktop sections.
3. `buzz sidebar sections create --name "CLI test" --icon "🧪"`: within a
few seconds, a "CLI test" section appears at the bottom of the desktop
sidebar.
4. `buzz sidebar sections move --section "CLI test" --channel <uuid>
--channel <uuid>`: both channels move under "CLI test" in desktop.
5. In desktop, rename the section. Then run `buzz sidebar sections
list`, which shows the new name.
6. `buzz sidebar sections delete --section <id>`: the section
disappears, and its channels return to the Channels list.
The author ran these steps and confirmed the result on an earlier head.
**Live re-test on this head** (rebased on `main` at 7fe3e6f), with the
Buzz Desktop credential provider against the production relay:
- `list` read the existing desktop-written sidebar (2 sections, 9
assignments).
- `create` (with an icon), `move`, a repeated `move` (published nothing;
`"unchanged"`), `update --name`, `list`, `unassign`, and `delete` were
all accepted.
- After cleanup, `list` output was byte-for-byte equal to the snapshot
taken before the test.
**`buzz-app` Desktop interop:** Buzz Desktop now ships from
`block/buzz-app`, so I checked the CLI document against its code at
`main` as well. `projectSidebarRecord` and `projectSidebarPreferences`
(`src/features/relay/`) project the CLI document to the same sections,
and the projected record equals the CLI document, so Desktop does not
republish it. The document also satisfies the Desktop signer rules in
`src-tauri/src/identity.rs` (`version: 1`, meta keys `v`/`s`/`a`,
section fields `name`/`icon`/`live`/`order`, 16-hex device ids).
Note: Desktop loads `channel-sections` by fetch, not by live
subscription, so a CLI edit shows in an open Desktop on its next sidebar
refresh (for example, a restart), not instantly. This is the same for
edits from another device.
Requested in Buzz:
`buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=2f3f2d152dc8a2cf2e75d803e7822faea28c24c163158feb97edfc57e2106c0e`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
667 lines
23 KiB
Rust
667 lines
23 KiB
Rust
//! The synced register-tree documents: channel sections (kind:30078,
|
|
//! d=`channel-sections`) and sort modes (d=`channel-sort`).
|
|
//!
|
|
//! Wire-compatible with desktop `channelSectionsSync.ts`, `channelSortSync.ts`,
|
|
//! `sidebarLaneStore.ts` and `sidebarLwwMap.ts`: a tree of per-field
|
|
//! last-writer-wins registers `[v, dev, value]` under `meta`, plus the
|
|
//! projected legacy fields that meta-less clients (mobile) read and write.
|
|
//! A [`Codec`] describes one document's shape, legacy import and projection.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde_json::{json, Map, Value};
|
|
|
|
/// Register tree: `{"s": {section_id: {name, icon, order, live}}, "a": {channel_id: section_id|null}}`.
|
|
pub(crate) type Tree = Map<String, Value>;
|
|
|
|
/// Live-section cap shared with desktop; larger documents are never published.
|
|
pub(crate) const MAX_SECTIONS: usize = 100;
|
|
/// Live-assignment cap shared with desktop.
|
|
pub(crate) const MAX_ASSIGNMENTS: usize = 1_000;
|
|
/// Cap on non-null sort registers shared with desktop.
|
|
pub(crate) const MAX_SORT_GROUPS: usize = 104;
|
|
|
|
/// Device id desktop stamps on registers imported from meta-less payloads.
|
|
const LEGACY_DEV: &str = "0000000000000000";
|
|
const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1;
|
|
|
|
/// One live section, densely renumbered by `(order, id)`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub(crate) struct Section {
|
|
pub id: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
pub order: usize,
|
|
}
|
|
|
|
/// The view every client renders: live sections and assignments to them.
|
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
pub(crate) struct Projection {
|
|
pub sections: Vec<Section>,
|
|
pub assignments: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Projection {
|
|
pub(crate) fn within_limits(&self) -> bool {
|
|
self.sections.len() <= MAX_SECTIONS && self.assignments.len() <= MAX_ASSIGNMENTS
|
|
}
|
|
}
|
|
|
|
/// `Number.isSafeInteger`, accepting integral floats such as `1.0`.
|
|
fn safe_int(value: &Value) -> Option<i64> {
|
|
let n = match value.as_i64() {
|
|
Some(n) => n,
|
|
None => {
|
|
let f = value.as_f64()?;
|
|
if f.fract() != 0.0 || f.abs() > MAX_SAFE_INTEGER as f64 {
|
|
return None;
|
|
}
|
|
f as i64
|
|
}
|
|
};
|
|
(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER)
|
|
.contains(&n)
|
|
.then_some(n)
|
|
}
|
|
|
|
fn is_dev(value: &Value) -> bool {
|
|
value
|
|
.as_str()
|
|
.is_some_and(|s| s.len() == 16 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
|
|
}
|
|
|
|
fn valid_reg(node: &Value, leaf: Leaf) -> Option<Value> {
|
|
let reg = node.as_array().filter(|reg| reg.len() == 3)?;
|
|
let v = safe_int(®[0]).filter(|v| *v >= 0)?;
|
|
(is_dev(®[1]) && leaf(®[2])).then(|| json!([v, reg[1], reg[2]]))
|
|
}
|
|
|
|
fn is_string(value: &Value) -> bool {
|
|
value.is_string()
|
|
}
|
|
fn is_string_or_null(value: &Value) -> bool {
|
|
value.is_string() || value.is_null()
|
|
}
|
|
fn is_order(value: &Value) -> bool {
|
|
safe_int(value).is_some()
|
|
}
|
|
fn is_bool(value: &Value) -> bool {
|
|
value.is_boolean()
|
|
}
|
|
fn is_sort_mode(value: &Value) -> bool {
|
|
value.is_null() || matches!(value.as_str(), Some("alpha" | "recent"))
|
|
}
|
|
|
|
type Leaf = fn(&Value) -> bool;
|
|
|
|
/// Shape of a register-tree node, as desktop `Shape`: a leaf register, fixed
|
|
/// fields, or a map whose every entry has one shape.
|
|
pub(crate) enum Shape {
|
|
Leaf(Leaf),
|
|
Fields(&'static [(&'static str, Shape)]),
|
|
Map(&'static Shape),
|
|
}
|
|
|
|
/// One synced document: its tree shape, how a meta-less payload becomes
|
|
/// registers, and the legacy fields projected from them.
|
|
pub(crate) struct Codec {
|
|
shape: Shape,
|
|
from_legacy: fn(&Value, i64) -> Value,
|
|
project: fn(&Tree) -> Value,
|
|
}
|
|
|
|
pub(crate) const SECTIONS: Codec = Codec {
|
|
shape: Shape::Fields(&[
|
|
(
|
|
"s",
|
|
Shape::Map(&Shape::Fields(&[
|
|
("name", Shape::Leaf(is_string)),
|
|
("icon", Shape::Leaf(is_string_or_null)),
|
|
("order", Shape::Leaf(is_order)),
|
|
("live", Shape::Leaf(is_bool)),
|
|
])),
|
|
),
|
|
("a", Shape::Map(&Shape::Leaf(is_string_or_null))),
|
|
]),
|
|
from_legacy: sections_from_legacy,
|
|
project: sections_view,
|
|
};
|
|
|
|
pub(crate) const SORT: Codec = Codec {
|
|
shape: Shape::Fields(&[("g", Shape::Map(&Shape::Leaf(is_sort_mode)))]),
|
|
from_legacy: sort_from_legacy,
|
|
project: |tree| json!({ "groups": sort_groups(tree) }),
|
|
};
|
|
|
|
/// Own entries of a JSON object, skipping the `__proto__` key desktop drops.
|
|
fn entries(node: &Value) -> impl Iterator<Item = (&String, &Value)> {
|
|
node.as_object()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|(key, _)| key.as_str() != "__proto__")
|
|
}
|
|
|
|
/// Keeps only well-formed nodes of `shape`; an invalid register or record is
|
|
/// dropped while its valid siblings survive (desktop `validTree`).
|
|
fn valid_node(node: &Value, shape: &Shape) -> Option<Value> {
|
|
if let Shape::Leaf(leaf) = shape {
|
|
return valid_reg(node, *leaf);
|
|
}
|
|
node.as_object()?;
|
|
let mut out = Map::new();
|
|
for (key, child) in entries(node) {
|
|
let sub = match shape {
|
|
Shape::Map(sub) => Some(*sub),
|
|
Shape::Fields(fields) => fields
|
|
.iter()
|
|
.find_map(|(name, sub)| (*name == key).then_some(sub)),
|
|
Shape::Leaf(_) => None,
|
|
};
|
|
if let Some(valid) = sub.and_then(|sub| valid_node(child, sub)) {
|
|
out.insert(key.clone(), valid);
|
|
}
|
|
}
|
|
Some(Value::Object(out))
|
|
}
|
|
|
|
fn valid_tree(fields: &Value, shape: &Shape) -> Option<Tree> {
|
|
match valid_node(fields, shape)? {
|
|
Value::Object(tree) => Some(tree),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// JS `Math.round`, as an integer when it is a safe one.
|
|
fn js_round(f: f64) -> Value {
|
|
let rounded = (f + 0.5).floor();
|
|
if rounded.abs() <= MAX_SAFE_INTEGER as f64 {
|
|
json!(rounded as i64)
|
|
} else {
|
|
json!(rounded)
|
|
}
|
|
}
|
|
|
|
/// Registers for a meta-less (legacy/mobile) sections payload, every value
|
|
/// stamped at `legacy_v`.
|
|
fn sections_from_legacy(json: &Value, legacy_v: i64) -> Value {
|
|
let stamp = |value: Value| json!([legacy_v, LEGACY_DEV, value]);
|
|
let mut s = Map::new();
|
|
for entry in json
|
|
.get("sections")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let (Some(id), Some(name), Some(order)) = (
|
|
entry.get("id").and_then(Value::as_str),
|
|
entry.get("name").and_then(Value::as_str),
|
|
entry.get("order").and_then(Value::as_f64),
|
|
) else {
|
|
continue;
|
|
};
|
|
let icon = entry
|
|
.get("icon")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|icon| !icon.is_empty());
|
|
s.insert(
|
|
id.to_string(),
|
|
json!({
|
|
"name": stamp(json!(name)),
|
|
"icon": stamp(json!(icon)),
|
|
"order": stamp(js_round(order)),
|
|
"live": stamp(json!(true)),
|
|
}),
|
|
);
|
|
}
|
|
let a: Map<String, Value> = entries(json.get("assignments").unwrap_or(&Value::Null))
|
|
.filter(|(_, target)| target.as_str().is_some_and(|t| s.contains_key(t)))
|
|
.map(|(id, target)| (id.clone(), stamp(target.clone())))
|
|
.collect();
|
|
json!({ "s": s, "a": a })
|
|
}
|
|
|
|
/// Registers for a meta-less sort payload; unknown group keys are kept and
|
|
/// unknown modes dropped, as desktop `parseChannelSortPayload`.
|
|
fn sort_from_legacy(json: &Value, legacy_v: i64) -> Value {
|
|
let g: Map<String, Value> = entries(json.get("groups").unwrap_or(&Value::Null))
|
|
.filter(|(_, mode)| matches!(mode.as_str(), Some("alpha" | "recent")))
|
|
.map(|(key, mode)| (key.clone(), json!([legacy_v, LEGACY_DEV, mode])))
|
|
.collect();
|
|
json!({ "g": g })
|
|
}
|
|
|
|
/// Decodes a `version: 1` payload, importing a meta-less one at `legacy_v`.
|
|
/// Returns `None` when unreadable, including an unknown `meta.v`.
|
|
pub(crate) fn decode(codec: &Codec, json: &Value, legacy_v: i64) -> Option<Tree> {
|
|
let is_one = |value: Option<&Value>| value.and_then(safe_int) == Some(1);
|
|
if !is_one(json.get("version")) {
|
|
return None;
|
|
}
|
|
match json.get("meta") {
|
|
None => valid_tree(&(codec.from_legacy)(json, legacy_v), &codec.shape),
|
|
Some(meta) if is_one(meta.get("v")) => {
|
|
let mut fields = meta.as_object()?.clone();
|
|
fields.remove("v");
|
|
valid_tree(&Value::Object(fields), &codec.shape)
|
|
}
|
|
Some(_) => None,
|
|
}
|
|
}
|
|
|
|
fn val<'a>(node: &'a Value, field: &str) -> Option<&'a Value> {
|
|
node.get(field)?.as_array()?.get(2)
|
|
}
|
|
|
|
/// Live sections by `(order, id)` with their stored `order`.
|
|
fn ranked(tree: &Tree) -> Vec<(String, String, Option<String>, i64)> {
|
|
let mut out: Vec<_> = tree
|
|
.get("s")
|
|
.map(entries)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|(_, node)| val(node, "live") == Some(&json!(true)))
|
|
.filter_map(|(id, node)| {
|
|
let name = val(node, "name")?.as_str()?.to_string();
|
|
let icon = val(node, "icon")
|
|
.and_then(Value::as_str)
|
|
.filter(|icon| !icon.is_empty())
|
|
.map(str::to_string);
|
|
let order = val(node, "order").and_then(safe_int).unwrap_or(0);
|
|
Some((id.clone(), name, icon, order))
|
|
})
|
|
.collect();
|
|
// JS compares strings by UTF-16 code unit.
|
|
out.sort_by(|a, b| {
|
|
a.3.cmp(&b.3)
|
|
.then_with(|| a.0.encode_utf16().cmp(b.0.encode_utf16()))
|
|
});
|
|
out
|
|
}
|
|
|
|
/// Highest stored `order` among live sections, or -1 when there are none.
|
|
fn max_live_order(tree: &Tree) -> i64 {
|
|
ranked(tree).iter().map(|s| s.3).max().unwrap_or(-1)
|
|
}
|
|
|
|
pub(crate) fn project(tree: &Tree) -> Projection {
|
|
let sections: Vec<Section> = ranked(tree)
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(order, (id, name, icon, _))| Section {
|
|
id,
|
|
name,
|
|
icon,
|
|
order,
|
|
})
|
|
.collect();
|
|
let assignments = tree
|
|
.get("a")
|
|
.map(entries)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|(channel, reg)| {
|
|
let target = reg.as_array()?.get(2)?.as_str()?;
|
|
sections
|
|
.iter()
|
|
.any(|s| s.id == target)
|
|
.then(|| (channel.clone(), target.to_string()))
|
|
})
|
|
.collect();
|
|
Projection {
|
|
sections,
|
|
assignments,
|
|
}
|
|
}
|
|
|
|
fn sections_view(tree: &Tree) -> Value {
|
|
let Projection {
|
|
sections,
|
|
assignments,
|
|
} = project(tree);
|
|
json!({ "sections": sections, "assignments": assignments })
|
|
}
|
|
|
|
/// Non-null sort modes by group key; `null` registers are explicit resets.
|
|
pub(crate) fn sort_groups(tree: &Tree) -> BTreeMap<String, String> {
|
|
tree.get("g")
|
|
.map(entries)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|(key, reg)| Some((key.clone(), reg.get(2)?.as_str()?.to_string())))
|
|
.collect()
|
|
}
|
|
|
|
/// The plaintext document: projected legacy fields plus the register tree.
|
|
pub(crate) fn encode(codec: &Codec, tree: &Tree) -> Value {
|
|
let mut doc = match (codec.project)(tree) {
|
|
Value::Object(fields) => fields,
|
|
_ => Map::new(),
|
|
};
|
|
let mut meta = tree.clone();
|
|
meta.insert("v".into(), json!(1));
|
|
doc.insert("version".into(), json!(1));
|
|
doc.insert("meta".into(), Value::Object(meta));
|
|
Value::Object(doc)
|
|
}
|
|
|
|
fn max_version(node: &Value) -> i64 {
|
|
match node {
|
|
Value::Array(reg) => reg.first().and_then(safe_int).unwrap_or(0),
|
|
Value::Object(map) => map.values().map(max_version).max().unwrap_or(0),
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// The version and device every register written by one edit shares.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct Stamp {
|
|
pub now_ms: i64,
|
|
pub dev: String,
|
|
}
|
|
|
|
/// One edit against a tree. Each call to [`Edit::set`] that changes a leaf
|
|
/// writes a register stamped `max(now, newest + 1)`, like desktop `setRegs`.
|
|
pub(crate) struct Edit<'a> {
|
|
tree: &'a mut Tree,
|
|
reg: (i64, String),
|
|
}
|
|
|
|
impl<'a> Edit<'a> {
|
|
pub(crate) fn new(tree: &'a mut Tree, stamp: &Stamp) -> Self {
|
|
let newest = tree.values().map(max_version).max().unwrap_or(0);
|
|
let v = stamp.now_ms.max(newest + 1);
|
|
Self {
|
|
tree,
|
|
reg: (v, stamp.dev.clone()),
|
|
}
|
|
}
|
|
|
|
fn current(&self, path: &[&str]) -> Option<&Value> {
|
|
let (first, rest) = path.split_first()?;
|
|
let mut node = self.tree.get(*first)?;
|
|
for key in rest {
|
|
node = node.get(key)?;
|
|
}
|
|
node.as_array()?.get(2)
|
|
}
|
|
|
|
fn write(&mut self, parents: &[&str], leaf: &str, value: Value) {
|
|
let reg = json!([self.reg.0, self.reg.1, value]);
|
|
let mut node = &mut *self.tree;
|
|
for key in parents {
|
|
let child = node.entry(key.to_string()).or_insert_with(|| json!({}));
|
|
if !child.is_object() {
|
|
*child = json!({});
|
|
}
|
|
let Value::Object(map) = child else {
|
|
return;
|
|
};
|
|
node = map;
|
|
}
|
|
node.insert(leaf.to_string(), reg);
|
|
}
|
|
|
|
/// Writes `value` at `path` unless it already holds that value.
|
|
pub(crate) fn set(&mut self, path: &[&str], value: Value) {
|
|
let Some((leaf, parents)) = path.split_last() else {
|
|
return;
|
|
};
|
|
if self.current(path) != Some(&value) {
|
|
self.write(parents, leaf, value);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn create_section(&mut self, id: &str, name: &str, icon: Option<&str>) {
|
|
let order = max_live_order(self.tree) + 1;
|
|
self.set(&["s", id, "name"], json!(name));
|
|
self.set(&["s", id, "icon"], json!(icon));
|
|
self.set(&["s", id, "order"], json!(order));
|
|
self.set(&["s", id, "live"], json!(true));
|
|
}
|
|
|
|
/// Orders live sections `requested` first, then the rest in their current
|
|
/// relative order. Any change rewrites every live `order` register.
|
|
pub(crate) fn reorder(&mut self, requested: &[String]) {
|
|
let live: Vec<String> = project(self.tree)
|
|
.sections
|
|
.into_iter()
|
|
.map(|s| s.id)
|
|
.collect();
|
|
let mut ids: Vec<String> = Vec::with_capacity(live.len());
|
|
for id in requested.iter().chain(&live) {
|
|
if live.contains(id) && !ids.contains(id) {
|
|
ids.push(id.clone());
|
|
}
|
|
}
|
|
let unchanged = ids
|
|
.iter()
|
|
.enumerate()
|
|
.all(|(index, id)| self.current(&["s", id, "order"]) == Some(&json!(index)));
|
|
if unchanged {
|
|
return;
|
|
}
|
|
for (index, id) in ids.iter().enumerate() {
|
|
self.write(&["s", id], "order", json!(index));
|
|
}
|
|
}
|
|
|
|
/// Tombstones the register at `path` when one exists (as a value or tombstone).
|
|
pub(crate) fn clear(&mut self, path: &[&str]) {
|
|
let Some((first, rest)) = path.split_first() else {
|
|
return;
|
|
};
|
|
let exists = rest
|
|
.iter()
|
|
.fold(self.tree.get(*first), |node, key| node?.get(key))
|
|
.is_some();
|
|
if exists {
|
|
self.set(path, Value::Null);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const DEV: &str = "0123456789abcdef";
|
|
|
|
fn stamp(now_ms: i64) -> Stamp {
|
|
Stamp {
|
|
now_ms,
|
|
dev: DEV.into(),
|
|
}
|
|
}
|
|
|
|
fn reg(v: i64, value: Value) -> Value {
|
|
json!([v, DEV, value])
|
|
}
|
|
|
|
/// A document in the shape desktop `encodeDoc` publishes.
|
|
fn desktop_doc() -> Value {
|
|
json!({
|
|
"version": 1,
|
|
"sections": [],
|
|
"assignments": {},
|
|
"meta": {
|
|
"v": 1,
|
|
"s": {
|
|
"b": {"name": reg(5, json!("Beta")), "icon": reg(5, Value::Null),
|
|
"order": reg(5, json!(1)), "live": reg(5, json!(true))},
|
|
"a": {"name": reg(4, json!("Alpha")), "icon": reg(4, json!("🚀")),
|
|
"order": reg(4, json!(0)), "live": reg(4, json!(true))},
|
|
"dead": {"name": reg(3, json!("Gone")), "icon": reg(3, Value::Null),
|
|
"order": reg(3, json!(2)), "live": reg(6, json!(false))},
|
|
"bad": {"name": [7, "not-a-dev", "Bad"], "order": reg(7, json!(3)),
|
|
"live": reg(7, json!(true))},
|
|
},
|
|
"a": {
|
|
"c1": reg(8, json!("a")),
|
|
"c2": reg(8, json!("dead")),
|
|
"c3": reg(9, Value::Null),
|
|
},
|
|
"future": reg(1, json!("ignored")),
|
|
},
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_desktop_document_and_projects_live_state() {
|
|
let tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable");
|
|
let view = project(&tree);
|
|
let names: Vec<_> = view
|
|
.sections
|
|
.iter()
|
|
.map(|s| (s.name.as_str(), s.order))
|
|
.collect();
|
|
// `bad` keeps its valid registers but has no name, so it is not live.
|
|
assert_eq!(names, [("Alpha", 0), ("Beta", 1)]);
|
|
assert_eq!(view.sections[0].icon.as_deref(), Some("🚀"));
|
|
assert_eq!(
|
|
view.assignments,
|
|
BTreeMap::from([("c1".to_string(), "a".to_string())])
|
|
);
|
|
assert!(!tree.contains_key("future"));
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_payloads_are_rejected() {
|
|
for payload in [
|
|
json!({"version": 2, "meta": {"v": 1}}),
|
|
json!({"version": 1, "meta": {"v": 2}}),
|
|
json!({"meta": {"v": 1}}),
|
|
json!("text"),
|
|
] {
|
|
assert!(decode(&SECTIONS, &payload, 0).is_none(), "{payload}");
|
|
}
|
|
let bad_version = json!({"version": 1.0, "meta": {"v": 1, "s": {"x": {
|
|
"order": [i64::MIN, DEV, 0], "live": [-1, DEV, true], "name": [1, DEV, "X"]}}}});
|
|
let tree = decode(&SECTIONS, &bad_version, 0).expect("readable");
|
|
assert_eq!(tree["s"]["x"], json!({"name": [1, DEV, "X"]}));
|
|
}
|
|
|
|
#[test]
|
|
fn imports_mobile_flat_payload_at_the_event_version() {
|
|
let mobile = json!({
|
|
"version": 1,
|
|
"sections": [
|
|
{"id": "x", "name": "Team", "icon": " ", "order": 1.6},
|
|
{"id": "y", "name": "Ops", "order": 0},
|
|
{"name": "no id", "order": 0},
|
|
],
|
|
"assignments": {"c1": "x", "c2": "missing"},
|
|
});
|
|
let tree = decode(&SECTIONS, &mobile, 42_000).expect("readable");
|
|
assert_eq!(tree["s"]["x"]["order"], json!([42_000, LEGACY_DEV, 2]));
|
|
assert_eq!(tree["s"]["x"]["icon"], json!([42_000, LEGACY_DEV, null]));
|
|
let view = project(&tree);
|
|
let names: Vec<_> = view.sections.iter().map(|s| s.name.as_str()).collect();
|
|
assert_eq!(names, ["Ops", "Team"]);
|
|
assert_eq!(view.assignments.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn encode_round_trips_and_carries_the_projection() {
|
|
let tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable");
|
|
let doc = encode(&SECTIONS, &tree);
|
|
assert_eq!(doc["meta"]["v"], json!(1));
|
|
assert_eq!(doc["sections"][0]["name"], json!("Alpha"));
|
|
assert!(doc["sections"][1].get("icon").is_none());
|
|
assert_eq!(doc["assignments"], json!({"c1": "a"}));
|
|
assert_eq!(decode(&SECTIONS, &doc, 0).expect("readable"), tree);
|
|
}
|
|
|
|
#[test]
|
|
fn edits_share_one_stamp_newer_than_every_register() {
|
|
let mut tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable");
|
|
let mut edit = Edit::new(&mut tree, &stamp(1));
|
|
edit.create_section("n", "New", None);
|
|
edit.set(&["a", "c9"], json!("n"));
|
|
assert_eq!(tree["s"]["n"]["order"], reg(10, json!(2)));
|
|
assert_eq!(tree["a"]["c9"], reg(10, json!("n")));
|
|
|
|
let mut edit = Edit::new(&mut tree, &stamp(500));
|
|
edit.set(&["s", "n", "name"], json!("New"));
|
|
assert_eq!(
|
|
tree["s"]["n"]["name"],
|
|
reg(10, json!("New")),
|
|
"unchanged leaf"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reorder_puts_requested_first_and_keeps_the_rest() {
|
|
let mut tree = Tree::new();
|
|
let mut edit = Edit::new(&mut tree, &stamp(1));
|
|
for id in ["a", "b", "c"] {
|
|
edit.create_section(id, id, None);
|
|
}
|
|
let mut edit = Edit::new(&mut tree, &stamp(2));
|
|
edit.reorder(&["c".into(), "dead".into(), "c".into()]);
|
|
let ids: Vec<_> = project(&tree).sections.into_iter().map(|s| s.id).collect();
|
|
assert_eq!(ids, ["c", "a", "b"]);
|
|
assert_eq!(tree["s"]["a"]["order"], json!([2, DEV, 1]));
|
|
|
|
let before = tree.clone();
|
|
Edit::new(&mut tree, &stamp(3)).reorder(&["c".into(), "a".into()]);
|
|
assert_eq!(
|
|
tree, before,
|
|
"a reorder to the current order writes nothing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unassign_tombstones_only_existing_assignments() {
|
|
let mut tree = Tree::new();
|
|
let mut edit = Edit::new(&mut tree, &stamp(1));
|
|
edit.create_section("s1", "S", None);
|
|
edit.set(&["a", "c1"], json!("s1"));
|
|
let mut edit = Edit::new(&mut tree, &stamp(2));
|
|
edit.clear(&["a", "c1"]);
|
|
edit.clear(&["a", "never"]);
|
|
assert_eq!(tree["a"]["c1"], json!([2, DEV, null]));
|
|
assert!(tree["a"].get("never").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn sort_documents_decode_project_and_round_trip() {
|
|
let desktop = json!({
|
|
"version": 1,
|
|
"groups": {"dms": "recent"},
|
|
"meta": {"v": 1, "g": {
|
|
"dms": reg(4, json!("recent")),
|
|
"forums": reg(5, Value::Null),
|
|
"starred": reg(6, json!("newest")),
|
|
"section:x": reg(7, json!("alpha")),
|
|
}, "future": reg(1, json!("ignored"))},
|
|
});
|
|
let tree = decode(&SORT, &desktop, 0).expect("readable");
|
|
assert_eq!(tree["g"]["forums"], reg(5, Value::Null));
|
|
assert!(tree["g"].get("starred").is_none());
|
|
assert!(!tree.contains_key("future"));
|
|
assert_eq!(
|
|
sort_groups(&tree),
|
|
BTreeMap::from([
|
|
("dms".to_string(), "recent".to_string()),
|
|
("section:x".to_string(), "alpha".to_string()),
|
|
])
|
|
);
|
|
let doc = encode(&SORT, &tree);
|
|
assert_eq!(
|
|
doc["groups"],
|
|
json!({"dms": "recent", "section:x": "alpha"})
|
|
);
|
|
assert_eq!(decode(&SORT, &doc, 0).expect("readable"), tree);
|
|
|
|
let mobile = json!({"version": 1, "groups": {"dms": "recent", "x": "bogus", "n": null}});
|
|
let tree = decode(&SORT, &mobile, 42_000).expect("readable");
|
|
assert_eq!(tree["g"]["dms"], json!([42_000, LEGACY_DEV, "recent"]));
|
|
assert!(tree["g"].get("x").is_none() && tree["g"].get("n").is_none());
|
|
let bare = decode(&SORT, &json!({"version": 1, "groups": []}), 1).expect("readable");
|
|
assert!(bare["g"].as_object().is_some_and(Map::is_empty));
|
|
assert!(decode(&SORT, &json!({"version": 1, "meta": {"v": 2}}), 0).is_none());
|
|
}
|
|
}
|