Files
buzz/crates
Blake McAnally dbf5c06b74 feat(cli): sidebar sort mode per group (#8784)
> Stacks on #8781 (`bmcanally/cli-sidebar-stars-mutes`). The diff shows
only this PR's commits. Merge #8781 first.

## Summary

This change adds `buzz sidebar sort`. It sets how each sidebar group
orders its channels.

- `sidebar sort list` shows the groups that have a sort mode.
- `sidebar sort set --group <group> --mode <alpha|recent>` sets a mode.
- `sidebar sort reset --group <group>` returns a group to the default
mode, `alpha`.

A group is `starred`, `channels`, `forums`, `dms`, `section:<id>`, or a
section name.

The command edits the same synced document as Buzz desktop and mobile
(kind:30078, d=`channel-sort`).

This PR stacks on the stars, mutes, and project pins PR. That PR stacks
on the sections PR (#8759).

## Design

The sort document uses the same per-field last-writer-wins register tree
as the sections document. Each group key has one register. The value is
`"alpha"`, `"recent"`, or `null`. A `null` register means "use the
default".

`sidebar_doc.rs` now works with any document shape. A `Codec` describes
one document. It holds the tree shape, the import of meta-less payloads,
and the legacy fields to project. Sections and sort each have one
`Codec`. The decode, encode, and edit code is shared.

The shared head read and the single-publish edit moved to
`sidebar_sync.rs`. Sections and sort both call them.

`Edit::unassign` became `Edit::clear(path)`. Both sections and sort use
it. It writes a `null` register only when a register exists.

Safety rules are the same as for sections:

- A command makes one read of the sort head and one publish.
- A section name also reads the sections document.
- The CLI never publishes over a head it cannot decode.
- `created_at` is the larger of the current time and the head time plus
one.
- Each register version is newer than every register in the head.
- A dominated write exits with code 5.
- A command that changes nothing publishes nothing and prints
`"message": "unchanged"`.
- A result with more than 104 non-null groups fails with exit code 1
before it publishes.

Group names follow these rules:

- A fixed group name wins over a section with the same name.
- `section:<id>` must be an exact live section id when you set a mode. A
section name in this form is refused.
- Any `section:<id>` is accepted when you reset a mode.
- A section name must be unique.

Desktop does not remove the sort key of a deleted section. Only mobile
removes such keys, and only in its local copy. The CLI also does not
remove them. The stale key still shows in `sort list`. It still counts
toward the cap of 104. `sort reset --group section:<id>` clears it.

## Testing

What ran:

- `cargo test -p buzz-cli`: 526 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.
- All existing sections, stars, mutes, and projects tests pass without a
change to their checks.

The new tests use the axum fake relay. They call the production `run`
function. They cover these cases:

- Set and reset by fixed group, section name, section id, and
`section:<id>`.
- The document format: kind, `d` and `t` tags, `groups`, and `meta.g`
registers.
- An unchanged repeat, and a reset of a group with no register.
- Bad input fails before any publish.
- Ambiguous section names.
- `section:<name>` and `section: <id>` are refused and publish nothing.
- A deleted section: the key stays, set is refused, reset clears it.
- A mobile head is imported, and its other groups survive an edit.
- An unreadable sort head, an unreadable sections head, and a tampered
head are never overwritten.
- `created_at` and register versions are newer than the head.
- The cap of 104 groups, and a reset that makes room.
- A dominated write exits with the conflict error.

Interop evidence:

- Node 24 ran desktop's own TypeScript: `channelSortSync.ts`,
`sidebarLaneStore.ts`, and `sidebarLwwMap.ts`.
- Desktop's `encodeDoc` made a head document. It had a tombstone and a
`section:` key.
- The CLI edited that head (set, reset, and set over a tombstone). It
also made a document from an empty head and from a mobile head.
- For each CLI document, desktop's `decodeDoc` read it as a meta
document.
- For each CLI document, desktop's `projectSort` equals the CLI `groups`
field.
- For each CLI document, desktop's `canonical(encodeDoc(...))` equals
the CLI bytes. Desktop would not publish again.
- Desktop's `mergeTrees` of the desktop head and the CLI edit gives the
CLI document. The reverse merge changes nothing.
- The run printed `INTEROP OK`.

Mutation checks. For each guard, I broke it, ran the sidebar tests, and
saw a failure. Then I restored it.

- Group cap check: `enforces_the_group_cap_and_refuses_bad_heads`
failed.
- Live-section requirement for set: the deleted-section test and the
main journey failed.
- Sort mode leaf validation: the sort document test failed.
- Clear only an existing register: the sections unassign test and the
main journey failed.
- Unchanged no-op: the sections journey and the main journey failed.
- Version newer than the newest register: the edit-version tests failed.
- `created_at` after the head: seven tests failed.
- Legacy mode filter (a `null` legacy value): the sort document test
failed. I added a row for this case after the first mutation survived.
- Fixed group wins over a section name: five tests failed.
- Null registers excluded from the projection: six tests failed.
- Refusal of an unknown `meta.v`: four tests failed.
- Exact live id match for `section:<id>`: the main journey failed when
the check also accepted names.

## Manual test steps

Use a test identity. These commands write to your own sidebar. Run them
with `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` set. Open Buzz desktop on
the same identity.

1. Build the CLI: `cargo build --release -p buzz-cli`. Use
`./target/release/buzz` below.
2. Run `buzz sidebar sort list`. Success: it prints
`{"default":"alpha","groups":{...}}`.
3. Run `buzz sidebar sections create --name "Sort test"`. Note the
`section_id`.
4. Run `buzz sidebar sort set --group dms --mode recent`. Success: the
output has `"group":"dms"` and an `event_id`. In desktop, the Direct
Messages group changes to Recent within a few seconds.
5. Run the same command again. Success: the output has
`"message":"unchanged"` and `"event_id":null`.
6. Run `buzz sidebar sort set --group "Sort test" --mode recent`.
Success: the output has `"group":"section:<id>"`. The section in desktop
sorts by Recent.
7. Run `buzz sidebar sort list`. Success: `groups` has `dms` and
`section:<id>`, both `recent`.
8. In desktop, set the Starred group to Recent. Run `buzz sidebar sort
list`. Success: `starred` shows `recent`.
9. Run `buzz sidebar sort reset --group dms`. Success: `dms` is back to
A to Z in desktop. `sort list` no longer shows `dms`.
10. Run `buzz sidebar sections delete --section "Sort test"`. Run `buzz
sidebar sort list`. Success: `section:<id>` is still there.
11. Run `buzz sidebar sort set --group section:<id> --mode alpha`.
Success: it fails with exit code 1 and a not-found message.
12. Run `buzz sidebar sort reset --group section:<id>`. Success: it
publishes. `sort list` no longer shows the key.
13. Run `buzz sidebar sort set --group nope --mode recent`. Success: it
fails with exit code 1 and publishes nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:02:11 -06:00
..