Development & Testing

All toolchains are managed via mise (mise.toml, Rust pinned).

mise install        # install Rust 1.97.1

Engine + CLI (natively on macOS)

wusel-core and wusel (without the fuse feature) build without a FUSE driver:

mise run check      # cargo check (wusel-core + wusel, no FUSE)
mise run test       # cargo test
mise run clippy

Testing strategy

A pyramid — we do not boot a Nextcloud for every run.

  • Unit tests (no server). Pure logic: PROPFIND parsing, SQLite state, sync-state decisions, three-way merge, path handling. Fast, deterministic, run everywhere.

  • Purpose-built fake servers (no Nextcloud, no mocking crate). Protocols that are not WebDAV get a few lines of hand-rolled tokio server in the test itself: the notify_push websocket and the self-signed-TLS handshake. No HTTP-mocking dependency is pulled in for this — see the dependency policy in Architecture.

  • Standalone WebDAV mock (wusel-mock). A tiny binary crate that serves a real directory as a Nextcloud-style user root (PROPFIND Depth 0/1, range GET, and the write verbs). It is pure Rust with a single dependency (tokio) — deliberately no Python and no mocking framework, to keep the project to one language and few dependencies. Because it mirrors a directory on disk, exercising change detection is just echo/rm in that directory, and fixtures are staged by creating files rather than by writing XML. Two fault-injection markers in a file’s name provoke server behaviour that is otherwise hard to stage: .fail-once (the first PUT answers 500) and .no-etag* (PUT/MOVE answer without an ETag). Start it with mise run mock (--root DIR), point a mount at it, or let the integration tests under crates/wusel-mock/tests/ drive the real client against it — that is where every server-facing engine test lives.

All three tiers run under mise run test — they are ordinary cargo test cases (the standalone mock via an integration test; wusel-mock is also a library, so tests can drive it in-process).

On Linux mise run test is not a bare cargo test: scripts/test.sh wraps the run in a private D-Bus session with an empty, unlocked keyring (dbus-run-session + gnome-keyring-daemon, files under a temporary XDG_DATA_HOME). Install those two on a fresh machine — Fedora sudo dnf install gnome-keyring dbus-daemon, Debian/Ubuntu sudo apt-get install gnome-keyring dbus-bin; without them the suite stops and says so rather than testing less than it claims.

It buys two things. The credential tests answer the same on an unlocked GNOME desktop and in a bare container, instead of depending on whether the machine happens to have a Secret Service. And no test can touch your own login keyring — the same account key the product uses. See Credential storage in Architecture for how the keyring’s failure modes are covered without a keyring at all.

  • Real FUSE mount (Linux, /dev/fuse). mise run fuse-test mounts the full stack — engine + FUSE — against the in-process wusel-mock and drives it through the kernel: ls, cat, stat, statfs, then unmount. It needs a Linux container, so it is not part of the native mise run test; run it via podman (see below). This is the automated counterpart to the manual mount check.

  • Real Nextcloud — automated, nightly. scripts/e2e-nextcloud.sh drives a FUSE-mounted wusel against a real Nextcloud container: read/hydration, write/upload, pin, and the opt-in 3-way merge on a genuine 412 conflict. This is the tier a mock cannot stand in for (ETag propagation, chunked-upload NG, the server’s own conditional-request behaviour). Run it locally with mise run e2e-local (podman); in CI it is the E2E workflow, see below. Anything the script still cannot cover — live notify_push, the desktop integration in a real session — stays a manual check against our own instance (see Trying it out).

Continuous integration (GitHub Actions)

Four workflows under .github/workflows/. All of them go through mise, so CI runs the same pinned toolchain and the same mise run … tasks as a local checkout — there is no separate CI script to keep in sync.

Workflow Trigger What it does

CI (ci.yml)

every push to main and every pull request

Formatting, licence headers, clippy (-D warnings), check, the native test suite, and a Linux build of the FUSE frontend. The real-mount e2e is not run here (it needs a privileged /dev/fuse); CI only proves the frontend builds.

E2E (e2e.yml)

nightly, plus manual dispatch

The real-Nextcloud end-to-end run above. It is gated on the code having changed since the last successful run, so a docs-only day costs nothing.

Docs (pages.yml)

every push to main and every pull request

Builds this Antora site; pushes to main also publish it to GitHub Pages at itbh-at.github.io/wusel. Pull requests build only, as validation.

Release (release.yml)

a v* tag

Builds the Fedora RPM for x86_64 and aarch64 and attaches both to a GitHub Release. See Installation.

Before pushing, mise run fmt-check, mise run clippy, mise run check and mise run test reproduce the CI job locally.

Documentation diagrams

The diagrams under documentation/diagrams/*.d2 are rendered to committed SVGs, and the docs build embeds those — it never runs d2, which is why the Pages workflow installs nothing but antora. The workflow after editing one:

mise run docs-diagrams        # re-render, then commit the changed SVGs

Forgetting that step used to be invisible: the site would build green and ship a picture that no longer matched the page around it. It is now caught, and without adding a d2 dependency to the build — mise run docs-diagrams records the hash of every source in diagrams/rendered.sha256, and documentation/check-diagrams.sh compares them. The docs build calls it (refusing an official build, warning in watch mode), so CI does too.

Sources beginning with an underscore are shared partials imported with …​@name, not diagrams — they are not rendered, but they are hashed, since a change to _steps.d2 invalidates every diagram that imports it.

Mounting on Linux (podman container)

FUSE needs a kernel driver → Linux container with /dev/fuse:

mise run fuse-build   # build wusel including FUSE
mise run fuse-test    # run the real-mount e2e test (headless)
mise run fuse-shell   # interactive shell with /dev/fuse
# in the shell:
mise exec -- cargo run -p wusel --features fuse -- mount /mnt/nc

The podman-*.sh scripts probe where the podman VM can see the repo (scripts/podman-lib.sh), in this order:

  1. The repo path itself — always true under podman’s standard macOS shares (/Users, /private, /var/folders) and on a Linux host.

  2. A disk share: a repo under /Volumes/<disk> that the VM mounts at /var/mnt/<disk> (setup below). The build then runs in place; the build cache (target-linux/, gitignored) lives with the repo and stays incremental, off the boot disk.

  3. Fallback: an rsync mirror at /private/tmp/wusel-linux. Container-side changes never reach the original tree, and podman-build.sh deletes the mirror’s build cache after each build so it cannot fill the boot disk.

Building from an external disk (one-time VM setup)

To let the VM see a repo on an external disk (e.g. /Volumes/<disk>), share the disk into the podman machine — two steps, surviving VM restarts:

  1. Add the disk to the Mounts array of ~/.config/containers/podman/machine/libkrun/podman-machine-default.json (copy an existing entry; Source/Target = /Volumes/<disk>, a unique Tag), then podman machine stop && podman machine start.

  2. Mount it inside the guest (Fedora CoreOS: / is immutable, so the target is /var/mnt; the SELinux context matches podman’s own shares — without it, containers get Permission denied):

    podman machine ssh "sudo mkdir -p /var/mnt/<disk> && \
      echo '<tag> /var/mnt/<disk> virtiofs rw,nofail,context=system_u:object_r:nfs_t:s0 0 0' \
      | sudo tee -a /etc/fstab && sudo systemctl daemon-reload && sudo mount /var/mnt/<disk>"
The VM’s disk is a sparse file: it grows as data is written and does not shrink when files inside are deleted, so podman system prune alone leaves the host file at its high-water mark. To hand the space back, trim inside the guest: podman machine ssh "sudo fstrim -av". If a container build ever fails, check /var/tmp in the guest too — podman leaves the unpacked build context behind and nothing collects it.

podman machine init recreates the VM from scratch — redo both steps then (the scripts fall back to mirroring automatically until you do).

The mount is Linux-only, so on macOS use the same podman container. Native macOS support (a File Provider frontend, not FUSE) is far-future, experimental work. See Trying it out for a step-by-step guide.

Release build (Linux)

To run wusel as a normal binary instead of through cargo, build it in release mode on Linux (the fuse feature needs the kernel driver):

mise run build-release      # cargo build --release --features fuse -p wusel

The binary is then at target/release/wusel — run it from there, or put it on your PATH wherever you like. For end users the distribution format is the Fedora RPM (packaging/rpm/, built by mise run rpm); a Debian package is not built yet — see Project, licence & distribution.

The file manager’s cloud-provider registration is a separate step (it goes into a system data dir, which a file manager scans but ~/.local/share is not), run as root: wusel desktop install-provider. For the full file-manager story (sidebar status, per-file emblems, context-menu actions, and the build prerequisites for the native extension), see File-manager integration.

Why this separation?

The FUSE driver is kernel-dependent; the rest of the logic (auth, WebDAV, sync, state) is not. That is why it lives in wusel-core and is tested natively on macOS in seconds. Only the thin mount adapter needs Linux — and you rarely touch it once it stands.