Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,24 @@
- Review changed functions after `cargo fmt`. Formatter success verifies mechanical layout; also verify visually that logical chunks are clearly separated and easy to scan.
- Apply these rules to new and materially changed code. Do not reformat unrelated existing code solely to adopt them.

## Rust Style
- Rust code follows the [Google Rust style guide](https://google.github.io/styleguide/rustguide.html) as application code. Its rules that Clippy can check are workspace lints in `Cargo.toml`, configured in `clippy.toml`. Fix a warning rather than suppressing it. When a site truly cannot comply, use `#[expect(lint, reason = "...")]` with the concrete constraint, never `#[allow]`.
- Each `use` statement imports from one module: `use foo::bar::{Bar, Baz};`, not `use foo::{Foo, bar::Bar};`. `tests/repository_guards/use_statements.rs` checks this because rustfmt's `imports_granularity` is unstable.
- Do not use glob imports, except `use super::*` in test modules and third-party preludes.
- Import type and trait names and use them unqualified; call functions through their module (`fs::read`). Keep ambiguous names such as `io::Error` and `fmt::Result` module-qualified. A gated import carries the same `#[cfg(...)]` as the code that uses it.
- Borrow instead of cloning or taking ownership (`&str`, `&[T]`, `Option<&T>`). Replace more than three bool parameters with enums or an options struct.
- Use `expect` with a reason instead of `unwrap` in production code. Give assertions messages that explain the invariant, and document panics in `# Panics`.
- Never reach inside a poisoned lock. Use `expect` when the critical section cannot panic, or a fallback that leaves the data alone. Tests that serialize on process-wide state use `test_env::lock()` or a `test_env::Lease`.
- `catch_unwind` is reserved for the reviewed worker-isolation sites that carry an `#[expect]` reason. Tests observe panics through a joined scoped thread.
- Getters are `foo()`/`foo_mut()`, a fallible constructor is `try_new`, and type lifetimes have descriptive names. Every `unsafe` block has a `// SAFETY:` comment that says why it is sound.

## Coupled Changes
- Config changes often require `config.example.toml`, `docs/CONFIG.md`, configurator models/views, schema behavior, and tests.
- Tool/action/keybinding changes often require config defaults, action metadata, help/command UI, toolbar UI, configurator labels/search, docs, and tests.
- Daemon, service, shortcut, path, and packaging changes often need updates across `src/daemon/`, `src/paths/`, `src/systemd_user_service.rs`, `src/shortcut_hint.rs`, `configurator/src/app/daemon_setup/`, and `packaging/`.

## Validation
- Full local CI is `./tools/lint-and-test.sh`. It needs the .NET SDK selected by `global.json`. Without it, `cargo test` still runs the Rust source guards in `tests/repository_guards` (process sites, config writers, shared dependencies, no Python), but not the C# checks, including the source-coverage check that finds `.rs` files Cargo never compiles.
- Full local CI is `./tools/lint-and-test.sh`. It needs the .NET SDK selected by `global.json`. Without it, `cargo test` still runs the Rust source guards in `tests/repository_guards` (process sites, config writers, shared dependencies, use statements, no Python), but not the C# checks, including the source-coverage check that finds `.rs` files Cargo never compiles.
- That script runs the C# repository checks (assets, version, nixpkgs recipe, source coverage, legacy tools), the C# tool tests (which also run the packaging shell contracts), `cargo fmt --all -- --check`, clippy with all targets/features, all-feature tests, and no-default-feature tests.
- Run `git diff --check` before handoff. When relevant files are untracked and the index must stay unchanged, check those files directly as well.
- For docs-only `AGENTS.md` edits, make new files visible to Git before whitespace checks, for example `rg --files --hidden -g AGENTS.md -0 | xargs -0 git add -N --` followed by `git diff --check`.
Expand Down
18 changes: 18 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ default-members = ["."]

[workspace.lints.clippy]
cognitive_complexity = "warn"
# Rules from the Google Rust style guide that Clippy can check. CI promotes
# these warnings to errors; clippy.toml holds their settings.
absolute_paths = "warn"
enum_glob_use = "warn"
fn_params_excessive_bools = "warn"
missing_assert_message = "warn"
missing_panics_doc = "warn"
must_use_candidate = "warn"
needless_for_each = "warn"
needless_pass_by_value = "warn"
redundant_clone = "warn"
ref_option = "warn"
return_self_not_must_use = "warn"
string_add = "warn"
undocumented_unsafe_blocks = "warn"
unnecessary_safety_comment = "warn"
unwrap_used = "warn"
wildcard_imports = "warn"

[lints]
workspace = true
Expand Down
3 changes: 1 addition & 2 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

fn main() {
println!("cargo:rerun-if-env-changed=WAYSCRIBER_RELEASE_VERSION");
Expand Down
13 changes: 13 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# Keep functions within the agreed cognitive-complexity budget. Clippy emits a
# warning above this value, and CI promotes warnings to errors.
cognitive-complexity-threshold = 20

# Tests may panic for assertions, so `unwrap_used` applies to production code.
allow-unwrap-in-tests = true

# Never reach inside a poisoned lock, and do not rely on catching panics.
disallowed-methods = [
{ path = "std::panic::catch_unwind", reason = "panics are bugs; recover only as defense in depth with a reviewed `#[expect]`" },
{ path = "std::sync::PoisonError::get_mut", reason = "a poisoned lock may hold broken invariants" },
{ path = "std::sync::PoisonError::get_ref", reason = "a poisoned lock may hold broken invariants" },
{ path = "std::sync::PoisonError::into_inner", reason = "a poisoned lock may hold broken invariants" },
{ path = "std::sync::Mutex::clear_poison", reason = "a poisoned lock may hold broken invariants" },
{ path = "std::sync::RwLock::clear_poison", reason = "a poisoned lock may hold broken invariants" },
]
29 changes: 15 additions & 14 deletions configurator/src/app/blocking_jobs.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::fmt;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};

use tokio::sync::Semaphore;
use tokio::task;

const PRODUCTION_BLOCKING_JOB_LIMIT: usize = 2;
const SLOW_JOB_THRESHOLD: Duration = Duration::from_millis(250);
Expand Down Expand Up @@ -65,21 +65,21 @@ impl BlockingJobRunner {
.map_err(|_| format!("{kind} blocking job queue is unavailable"))?;
report_slow_phase(kind, "queue wait", queued_at.elapsed());

let joined = tokio::task::spawn_blocking(move || {
let joined = task::spawn_blocking(move || {
let _permit = permit;
let started_at = Instant::now();
let outcome = catch_unwind(AssertUnwindSafe(job));
let result = job();
report_slow_phase(kind, "execution", started_at.elapsed());
outcome
result
})
.await;

let outcome =
joined.map_err(|err| format!("{kind} blocking job did not complete: {err}"))?;

match outcome {
// A panicking job unwinds its blocking thread, which releases the
// permit; tokio reports the panic through the join error.
match joined {
Ok(result) => result,
Err(_) => Err(format!("{kind} blocking job panicked")),
Err(err) if err.is_panic() => Err(format!("{kind} blocking job panicked")),
Err(err) => Err(format!("{kind} blocking job did not complete: {err}")),
}
}
}
Expand All @@ -106,6 +106,7 @@ mod tests {
use std::sync::mpsc;
use std::thread;

use tokio::sync::oneshot;
use tokio::time::{Duration, timeout};

use super::*;
Expand Down Expand Up @@ -134,7 +135,7 @@ mod tests {
async fn blocking_work_is_offloaded_while_executor_work_progresses() {
let runner = BlockingJobRunner::new(1);
let caller_thread = thread::current().id();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (started_tx, started_rx) = oneshot::channel();
let (release_tx, release_rx) = mpsc::channel();

let job = tokio::spawn(async move {
Expand Down Expand Up @@ -169,7 +170,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn one_permit_prevents_a_second_closure_from_starting() {
let runner = BlockingJobRunner::new(1);
let (first_started_tx, first_started_rx) = tokio::sync::oneshot::channel();
let (first_started_tx, first_started_rx) = oneshot::channel();
let (first_release_tx, first_release_rx) = mpsc::channel();
let first_runner = runner.clone();
let first = tokio::spawn(async move {
Expand All @@ -186,7 +187,7 @@ mod tests {
.expect("first closure should start")
.expect("first closure should report startup");

let (second_started_tx, mut second_started_rx) = tokio::sync::oneshot::channel();
let (second_started_tx, mut second_started_rx) = oneshot::channel();
let second = tokio::spawn(async move {
runner
.run(BlockingJobKind::SessionCatalogLoad, move || {
Expand Down Expand Up @@ -238,7 +239,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_the_waiter_does_not_release_a_running_jobs_permit() {
let runner = BlockingJobRunner::new(1);
let (first_started_tx, first_started_rx) = tokio::sync::oneshot::channel();
let (first_started_tx, first_started_rx) = oneshot::channel();
let (first_release_tx, first_release_rx) = mpsc::channel();
let first_runner = runner.clone();
let first = tokio::spawn(async move {
Expand Down Expand Up @@ -268,7 +269,7 @@ mod tests {
"the detached blocking closure must retain its permit"
);

let (second_started_tx, mut second_started_rx) = tokio::sync::oneshot::channel();
let (second_started_tx, mut second_started_rx) = oneshot::channel();
let second = tokio::spawn(async move {
runner
.run(BlockingJobKind::SessionCatalogLoad, move || {
Expand Down
9 changes: 6 additions & 3 deletions configurator/src/app/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn load_app_stylesheet() {
// overlay crate's `v4_12` and then deprecates this method.
#[allow(deprecated)]
provider.load_from_data(include_str!("style.css"));
if let Some(display) = gtk::gdk::Display::default() {
if let Some(display) = Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
Expand All @@ -46,6 +46,9 @@ fn load_app_stylesheet() {
const APP_ID: &str = "org.wayscriber.Configurator";

use effects::spawn_effect;
use gtk::gdk::Display;
use gtk::gio::ApplicationFlags;
use wayscriber::domain::Language;

pub(crate) fn run(startup: StartupRequest) {
// Every launch is its own window, as it was under Iced: the overlay and
Expand All @@ -56,7 +59,7 @@ pub(crate) fn run(startup: StartupRequest) {
// safe: the guarded `ConfigDocument` save revision arbitrates writes.
let application = adw::Application::builder()
.application_id(APP_ID)
.flags(gtk::gio::ApplicationFlags::NON_UNIQUE)
.flags(ApplicationFlags::NON_UNIQUE)
.build();
let app = RelmApp::from_app(application);
// GTK must not parse our argv: `--open <destination>` is ours and was
Expand Down Expand Up @@ -92,7 +95,7 @@ pub(crate) struct AppWidgets {
bindings: Vec<Binding>,
/// The language the pages were built in. When the active language moves
/// away from it, the next refresh rebuilds them.
language: wayscriber::domain::Language,
language: Language,
chrome: shell::Chrome,
}

Expand Down
3 changes: 2 additions & 1 deletion configurator/src/app/component/effects.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use relm4::ComponentSender;
use relm4::adw::ApplicationWindow;

use crate::messages::CommandMessage;

Expand All @@ -11,7 +12,7 @@ use super::super::{daemon_setup, io, session_catalog};
pub(super) fn spawn_effect(
effect: Effect,
sender: &ComponentSender<ConfiguratorApp>,
root: &relm4::adw::ApplicationWindow,
root: &ApplicationWindow,
) {
use relm4::gtk::prelude::*;
match effect {
Expand Down
16 changes: 10 additions & 6 deletions configurator/src/app/component/rebuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
//! it, and how far the visible page is scrolled. This module carries those
//! across.

use std::iter;

use relm4::{ComponentSender, gtk};

use gtk::glib;
use gtk::prelude::*;
use gtk::glib::GString;
use gtk::prelude::{GtkWindowExt, *};
use wayscriber::i18n;

use super::super::pages;
use super::super::state::ConfiguratorApp;
Expand Down Expand Up @@ -56,7 +60,7 @@ impl WidgetPath {
}

fn children(widget: &gtk::Widget) -> impl Iterator<Item = gtk::Widget> {
std::iter::successors(widget.first_child(), gtk::Widget::next_sibling)
iter::successors(widget.first_child(), gtk::Widget::next_sibling)
}

/// The caret and selection of an editable control, in characters.
Expand All @@ -69,7 +73,7 @@ struct Caret {
/// View state from the pages being replaced, applied to the new ones.
pub(super) struct ViewState {
/// The stack name of the page the state was read from.
page: Option<gtk::glib::GString>,
page: Option<GString>,
/// Set when focus was inside the visible page; focus elsewhere sits in
/// persistent chrome, which the rebuild leaves alone.
focus: Option<(Option<WidgetPath>, Option<Caret>)>,
Expand All @@ -84,7 +88,7 @@ pub(super) fn rebuild_if_language_changed(
widgets: &mut AppWidgets,
sender: &ComponentSender<ConfiguratorApp>,
) -> Option<ViewState> {
let language = wayscriber::i18n::language();
let language = i18n::language();
if widgets.language == language {
return None;
}
Expand Down Expand Up @@ -123,7 +127,7 @@ fn capture(widgets: &AppWidgets) -> ViewState {
};
};

let focus = gtk::prelude::GtkWindowExt::focus(&widgets.root)
let focus = GtkWindowExt::focus(&widgets.root)
.filter(|focused| focused.is_ancestor(&page))
.map(|focused| {
let caret = focused
Expand Down Expand Up @@ -157,7 +161,7 @@ fn capture(widgets: &AppWidgets) -> ViewState {

fn descendants(widget: &gtk::Widget) -> impl Iterator<Item = gtk::Widget> {
let mut pending = vec![widget.clone()];
std::iter::from_fn(move || {
iter::from_fn(move || {
let next = pending.pop()?;
pending.extend(children(&next));
Some(next)
Expand Down
24 changes: 11 additions & 13 deletions configurator/src/app/component/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use relm4::{ComponentSender, adw, gtk};

use adw::prelude::*;

use gtk::gdk::{Key, ModifierType};
use gtk::glib::Propagation;
use wayscriber::i18n::{tr, trf};
use wayscriber::{build_info, i18n};

use crate::messages::Message;
use crate::models::TabId;
Expand Down Expand Up @@ -39,7 +42,7 @@ pub(super) fn build(
let sender = sender.clone();
root.connect_close_request(move |_| {
sender.input(Message::CloseRequested);
gtk::glib::Propagation::Stop
Propagation::Stop
});
}
// ---- Sidebar ----------------------------------------------------
Expand Down Expand Up @@ -267,24 +270,22 @@ pub(super) fn build(
// lets widgets such as SearchEntry perform their own Escape behavior.
controller.set_propagation_phase(gtk::PropagationPhase::Capture);
controller.connect_key_pressed(move |_, key, _, modifiers| {
if modifiers.contains(gtk::gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gtk::gdk::Key::f | gtk::gdk::Key::F)
{
if modifiers.contains(ModifierType::CONTROL_MASK) && matches!(key, Key::f | Key::F) {
sender.input(Message::SearchFocusRequested);
return gtk::glib::Propagation::Stop;
return Propagation::Stop;
}
if key == gtk::gdk::Key::Escape {
if key == Key::Escape {
// The recorder owns Escape while it is open so a recorded
// Escape becomes a binding. Confirmation cancel is the
// fallback when nothing is recording.
sender.input(Message::WindowEscapePressed);
}
// Tab is the user moving focus deliberately; a still-pending
// startup search focus must not steal it back later.
if matches!(key, gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab) {
if matches!(key, Key::Tab | Key::ISO_Left_Tab) {
sender.input(Message::StartupInteractionObserved);
}
gtk::glib::Propagation::Proceed
Propagation::Proceed
});
root.add_controller(controller);
}
Expand Down Expand Up @@ -325,7 +326,7 @@ pub(super) fn build(
search_entry,
seen_focus_serial: 0,
bindings,
language: wayscriber::i18n::language(),
language: i18n::language(),
chrome: Chrome {
build_identity,
content_page,
Expand Down Expand Up @@ -385,10 +386,7 @@ pub(super) fn retitle(widgets: &AppWidgets) {
}
chrome.build_identity.set_label(&trf(
"Version {} · commit {}",
&[
&wayscriber::build_info::version(),
&wayscriber::build_info::commit_hash(),
],
&[&build_info::version(), &build_info::commit_hash()],
));
widgets
.window_title
Expand Down
Loading
Loading