Rust, as Sioul uses it¶
This page is for a developer who has not written Rust. It does not teach the language: The Rust Programming Language ("the Book") does, and each section below names the chapter to read. It shows the parts of Rust that Sioul's code relies on, with short excerpts from the code itself, so that the first files you open read more easily. Start here has the map of the code.
The workspace and its crates¶
A crate is Rust's unit of compilation: a library or a program. A package is a folder with a Cargo.toml that builds one or more crates; Sioul has one per folder of crates/, and calls each a crate. Cargo, Rust's build tool, builds them, fetches their dependencies (other crates, from crates.io) and runs their tests. A workspace builds several packages together, with one Cargo.lock (the exact version of every dependency) and one target/ folder.
The root Cargo.toml declares it:
[workspace]
resolver = "3"
members = ["crates/sioul-core", "crates/sioul-cli", "crates/sioul-sync", "crates/sioul-app", "crates/sioul-learn"]
# The window needs Qt's development files (docs/building.md); everything else builds without them.
# sioul-learn (the spam filter's training) is for desktops: the phone never builds it.
default-members = ["crates/sioul-core", "crates/sioul-cli", "crates/sioul-sync", "crates/sioul-learn"]
[workspace.package]
version = "0.0.5"
edition = "2024"
rust-version = "1.89"
default-members: whatcargo buildandcargo testtake when no package is named. The window is left out, so that a computer without Qt builds the rest.-pnames one:cargo build -p sioul-app.version: Sioul's version, once for every crate (each saysversion.workspace = true); Android's build reads it there too (android-basics.md).edition = "2024": the edition of the language the code is written in. A few things in the code are new in it:unsafe extern "C"blocks,#[unsafe(no_mangle)], andif let … && let …chains (below). The Edition Guide lists the changes.rust-version = "1.89": the oldest compiler that builds Sioul.[workspace.dependencies]: each dependency's version is set once, at the root; a crate saysserde.workspace = truein its ownCargo.tomlto take it.- Profiles:
[profile.dev]keeps debug builds small (line tables only, no debug information for dependencies), and builds the cryptography crates optimised even in debug builds, since a passphrase's key derivation (Argon2) takes seconds without it.
The Book: chapter 7, packages, crates and modules, and chapter 14, Cargo workspaces.
Features and platforms¶
A feature is a switch a crate offers at build time. Sioul has one, insecure-test-tls, for tests against a local mail server only (building.md, "Testing against a local mail server"). crates/sioul-sync/Cargo.toml declares it under [features] (insecure-test-tls = []), and each crate that uses sync passes it on, under a feature of the same name (insecure-test-tls = ["sioul-sync/insecure-test-tls", …] in crates/sioul-cli/Cargo.toml, crates/sioul-app/Cargo.toml and crates/sioul-learn/Cargo.toml).
Code reads a feature with #[cfg(feature = "…")] or cfg!(feature = "…"). crates/sioul-sync/src/lib.rs refuses to build a release with it:
#[cfg(all(feature = "insecure-test-tls", not(debug_assertions)))]
compile_error!("insecure-test-tls accepts any certificate: for test builds only, never with --release");
The same attributes choose code per system. #[cfg(target_os = "android")] keeps an item for Android only; cfg!(target_os = "android") is a true or false the code can test. Dependencies can be per system too: crates/sioul-sync/Cargo.toml takes a different keyring backend for Linux, Windows, macOS and Android under [target.'cfg(…)'.dependencies], and the two programs take sioul-learn on computers only ([target.'cfg(not(target_os = "android"))'.dependencies]).
The Cargo Book: features, platform-specific dependencies.
Modules and visibility¶
A crate is a tree of modules. crates/sioul-core/src/lib.rs is the root of the core; each pub mod porch; there is the file src/porch.rs. A module with modules of its own is a file and a folder of the same name: crates/sioul-cli/src/mcp.rs declares mod protocol;, mod read; and others, which are src/mcp/protocol.rs, src/mcp/read.rs. It can also be a folder alone, whose mod.rs is the module: the core's spam filter, crates/sioul-core/src/spam/mod.rs, with spam/tokenize.rs and its other parts beside it.
What a module may use from another is set by visibility:
pub: seen from other crates. The core's modules and most of their functions arepub, since the command line, the window and sync all use them:sioul_core::stopped::Stopped::load.pub(crate): seen anywhere in the same crate, not outside. The window's modules use it for what they share with each other:pub(crate) fn tr()inbackend.rs.- nothing: seen only in the module and its children. The window's modules are declared
mod alarms;incrates/sioul-app/src/lib.rs: no other crate depends on them.
use brings names into a file: at the top of crates/sioul-app/src/backend.rs, use sioul_core::porch::{self, KnownSenders, SenderList, Triaged}; makes porch::gather, KnownSenders, SenderList and Triaged short. Inside a crate, crate:: starts from the crate's root and super:: from the module above.
Ownership and borrowing¶
Every value in Rust has one owner; when the owner goes, the value is freed. Other code can borrow it: &T reads it, &mut T changes it, and the compiler checks that no borrow outlives the value and that nothing reads a value while something else changes it. This replaces a garbage collector and prevents data races between threads. The Book: chapter 4.
What it looks like in Sioul:
-
Functions borrow; structs own.
Stopped::keep(crates/sioul-core/src/stopped.rs) takes borrowed text,&strand&Path, and makes owned values (String) only for what it keeps. It begins:pub fn keep(path: &Path, text: &str, task: &str, at: i64) -> Result<(), String> { let stopped = Stopped { text: text.trim().to_string(), at, task: task.to_string() };StringandPathBufown their text;&strand&Pathborrow it. Most functions take the borrowed kind, so that a caller can pass what it has without copying. -
clone()makes a copy the code then owns. It is used where a value must outlive the place it came from, such as a value moved into another thread. -
Arc, a counted reference, lets several threads own one value. The window's state that threads share isArc<Shared>(crates/sioul-app/src/backend.rs);Arc::cloneadds an owner, and amoveclosure takes it into the thread:let (qt, shared) = (qt.clone(), Arc::clone(&shared)); std::thread::spawn(move || { let checked: usize = load_config().mail_sources().iter().map(|s| sioul_sync::verify::verify_folder(&s.folder, false)).sum(); if checked > 0 { show(&qt, &shared); } });This is the end of the
startinvokable (backend.rs): the mail fetched before Sioul checked senders itself is checked once, on a thread, and the Porch is shown again if any was. -
Mutexand atomics let threads change what they share. AMutexholds a value that one thread at a time may reach:shared.watchers.lock()waits for it and returns a guard, which lets go when it goes out of scope (inside aResult: a thread that panicked while holding it leaves it poisoned).AtomicBoolandAtomicU64hold one flag or number that any thread can read and change without waiting. The Book: chapter 16. Pin<&mut Sioul>, in the bridge, is a mutable borrow of an object that must stay at one address in memory, as Qt's objects must: CXX-Qt asks for it wherever a method changes the object (cxx-qt.md).'staticin a type, as in&'static Translator, says that the borrowed value lives as long as the program.backend::tr()makes the translator once, in aOnceLock, and lends it to anyone.
Results, options and ?¶
Rust has no exceptions. A function that can fail returns Result<T, E>: Ok(value) or Err(error). A value that may be missing is Option<T>: Some(value) or None. The ? operator, after a Result or an Option, returns the error (or None) at once and otherwise gives the value. The Book: chapter 9.
From Stopped::keep: map_err turns the system's error into Sioul's, and ? returns it if there is one:
let fail = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(fail)?;
}
The same with an Option, in crates/sioul-app/src/lib.rs, where any step that finds nothing ends the function with None:
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
// The fields after the program's name, which ends at the last ")": the
// 22nd, when the process started, in hundredths of a second since boot.
let started: f64 = stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()?;
Two forms the code uses often, which older Rust did not have:
-
let … else: take the value or leave. Fromcrates/sioul-app/src/work.rs: -
if let … && let …(let chains, edition 2024): several conditions, each of which may unpack a value. Frombackend::start_watcher:
The error types¶
- The core returns
Result<T, String>: the error is a readable line, usually the file's path and what went wrong (…/stopped.toml: Permission denied (os error 13)). The window shows it in the status line as it is. - Sync has one error type,
SyncError(crates/sioul-sync/src/lib.rs): anenumwith one case per kind of failure (Network,Tls,Login,AppPassword,Disk…), each with its detail. It knows the Fluent message that says it (message_id, thensentence(tr, account)), whether the failure lasts, so that trying again later cannot help and may lock the account (is_lasting: a refused login, a certificate that does not check), and whether a new password can help (wants_password). It implementsDisplayandstd::error::Error, the two traits Rust's errors share. - No panics across the edges. A panic is Rust's crash of a thread, for bugs, never for expected failures. Where bad data could make one, the code catches it so that the window or Android's caller goes on:
std::panic::catch_unwindinbackend::coalesced(a page's computation) and in the functions Java calls through C (sioul_alarm_decideincrates/sioul-app/src/alarms.rs, and their kin), since a panic must never cross into C or Java. unwrap(), which panics on an error, belongs in tests.
Traits that matter here¶
A trait is a set of methods a type promises to have, as an interface in other languages. The Book: chapter 10.
- Derived traits:
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]above a struct writes those traits for it.Debugprints it in tests,Clonecopies it,Defaultmakes an empty one (PorchState::default(), andunwrap_or_default()falls back to it),PartialEqcompares it inassert_eq!,SerializeandDeserializeturn it into and out of TOML or JSON. Defaultfor the bridge: CXX-Qt makes the Rust side of theSioulobject withDefault::default(), hence#[derive(Default)]onSioulRust(cxx-qt.md).- Closures as arguments:
Fn,FnMutandFnOnceare the traits of functions passed as values.backend::coalescedtakeswork: impl Fn(&Arc<Shared>) + Send + 'static: a function that may be called several times, sent to another thread (Send), and borrowing nothing that could go away ('static).sioul_sync::watchtakesreport: impl FnMut(Result<Report, SyncError>), called after each fetch. - CXX-Qt's traits:
Threadinggives the bridge objectqt_thread(), andCxxQtTypegivesrust(), its Rust side.
Plain files: serde, toml and toml_edit¶
serde turns Rust values into text and back; toml and serde_json are its TOML and JSON formats. A struct that derives Serialize and Deserialize is read and written in one line each. crates/sioul-core/src/stopped.rs:
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stopped {
pub text: String,
/// Unix seconds.
pub at: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub task: String,
}
toml::from_str::<Stopped>(&text) reads one, toml::to_string(&stopped) writes one. #[serde(default)] lets a file without task be read (the field is then empty), and skip_serializing_if leaves it out of the file when it is empty.
The configuration is edited in place, with toml_edit, never written again from a struct: the file is yours, and its comments and order stay. crates/sioul-core/src/config.rs reads it into a DocumentMut, changes one value (set_account_item), checks that the result still reads as a Config, writes it beside and renames it over the old one (write_document).
JSON for the window: the bridge hands QML most of what it shows as JSON text, made with serde_json from structs of crates/sioul-core/src/view.rs (backend::json) or with the serde_json::json! macro for small objects.
Time: jiff¶
Sioul's dates and times use jiff: Zoned::now() is now in the system's time zone, Timestamp an instant (often stored as Unix seconds, Timestamp::now().as_second()), jiff::civil::Date a day without a zone, TimeZone::system() the system's zone. chrono and chrono-tz appear only where calcard, the iCalendar and vCard parser, needs them: crates/sioul-core/src/agenda.rs turns a jiff time zone into calcard's.
Threads and async¶
- The window and the command line are synchronous. Slow work goes to a plain thread,
std::thread::spawn, and its result comes back to the window's thread through CXX-Qt (cxx-qt.md). -
The network code is asynchronous inside
sioul-sync. IMAP, SMTP and DNS libraries are written for tokio, Rust's most used async runtime: anasync fnreturns a future, a task that a runtime runs, and.awaitwaits for another without holding a thread. Sync keeps this inside: the functions the window and the command line call make a small runtime on the current thread and wait for the result (crates/sioul-sync/src/fetch.rs):fn runtime() -> Result<tokio::runtime::Runtime, SyncError> { tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| SyncError::Network(e.to_string())) } pub(crate) fn block_on<T>(future: impl Future<Output = Result<T, SyncError>>) -> Result<T, SyncError> { runtime()?.block_on(future) }So
sioul_sync::sync(&account, &password)returns when the fetch is over, and a caller runs it on a thread of its own. -
A watcher is stopped or woken from outside through a
Control(crates/sioul-sync/src/fetch.rs): flags any thread can set (stop, fetch now, real time), and a way to wake the watcher's waits at once. - Between processes, a file two of them change is locked while it is read, changed and written (
sioul_core::filelock::with_lock: an advisory lock on a hidden.<name>.lockbeside it, throughstd::fs::File::lock): the window, the command line and a phone's background service may write the same file.
The Book: chapter 16, concurrency, and chapter 17, async.
Calling C, and being called from it¶
Rust calls C (and C++ written to look like C) through unsafe extern "C" blocks, and makes functions C can call with #[unsafe(no_mangle)] pub extern "C" fn. Sioul does both: the window calls its C++ helpers (crates/sioul-app/src/lib.rs, sioul_start_web_engine), and Android's C++ calls Rust (android-basics.md). Unsafe code is code whose safety the compiler cannot check, so the code says why it is sound: a function marked unsafe has a # Safety section in its comment, which says what its caller must promise (sioul_alarm_decide in crates/sioul-app/src/alarms.rs), and an unsafe { … } block has a // SAFETY: comment above it (crates/sioul-app/src/lib.rs has one at each). Some of the Android calls lack theirs: write one with any new block. The Book: chapter 20, unsafe Rust.
Tests¶
Tests sit beside the code, in a module compiled only for tests. The end of crates/sioul-core/src/stopped.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_line_left_then_done() {
let dir = std::env::temp_dir().join(format!("sioul-stopped-{}", std::process::id()));
let path = dir.join("stopped.toml");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(Stopped::load(&path), None);
Stopped::keep(&path, " the second paragraph, after the quote ", "t1", 100).unwrap();
assert_eq!(Stopped::load(&path), Some(Stopped { text: "the second paragraph, after the quote".into(), at: 100, task: "t1".into() }));
Stopped::keep(&path, "", "", 200).unwrap();
assert_eq!(Stopped::load(&path), None, "done: taken away");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]builds the module forcargo testonly;use super::*brings in the module above; each#[test]function is one test, which fails if it panics (assert_eq!panics when its two values differ).- Integration tests are files in a crate's
tests/folder, which use the crate as another crate would:crates/sioul-core/tests/porch.rs, with its invented mail intests/fixtures/. #[ignore]marks tests that need something outside (a local server, a stand-in of an API, a program such as Tesseract): they run only when asked.- Invented data only: reserved domains (
example.org, RFC 2606), fictional numbers, temporary folders; a test never reads the configuration of whoever runs it (sioul_sync::translatorreads none undercfg!(test)).
Running them:
| Command | What runs |
|---|---|
cargo test |
the default members' tests: the core's, sync's, the command line's and sioul-learn's |
cargo test -p sioul-core |
one crate's |
cargo test -p sioul-core stopped |
the tests whose full name holds stopped, as stopped::tests::a_line_left_then_done |
cargo test -p sioul-core --lib i18n::tests |
one module's, in the library alone |
cargo test -p sioul-core --test porch |
one integration test file |
cargo test -p sioul-sync --features insecure-test-tls -- --ignored stand_in |
the ignored test against the stand-in of Google Tasks, which must be running (building.md) |
cargo test -p sioul-app homecard |
the window's tests: they need Qt to build |
CI (.github/workflows/build.yml) runs cargo test --release --locked -p sioul-core -p sioul-sync -p sioul-cli on Linux, Windows and macOS; the window's own tests run only where someone runs them.
The Book: chapter 11.
Formatting and lints¶
- rustfmt: the repository has no rustfmt configuration, and its code is not laid out as rustfmt's defaults would lay it out (many lines are longer than its 100 columns).
cargo fmtwould rewrite most of each file it touches: match the layout of the file you edit instead. - clippy, Rust's linter: not run by CI, but the code answers it; the few functions with many arguments say so (
#[allow(clippy::too_many_arguments)]).cargo clippy -p sioul-coreruns it on one crate (clippy's documentation). - The house rules are in architecture.md: one task per function, comments that give the reason and the reference, a test for every rule the window applies. Each file starts with its licence (
// SPDX-License-Identifier: GPL-3.0-or-later) and a//!comment saying what it is for.
Documentation comments¶
//! at the top of a file describes the module; /// above an item describes the item. Both are Markdown, and cargo doc turns them into the API reference (the API reference on this site). Name the design note a module follows in its //! comment ((docs/tasks.md, "Where you stopped")): the notes and the code then lead to each other.