owlry 2.1.0: Lua config layer (Phase 3) #8

Merged
vikingowl merged 21 commits from feat/lua-config into main 2026-05-13 14:21:52 +02:00
37 changed files with 6196 additions and 130 deletions
+21
View File
@@ -0,0 +1,21 @@
# Pin the linker to GNU ld.bfd for x86_64 builds.
#
# Background: Arch's `extra/rust` package patches rustc to default to
# `-fuse-ld=lld`. mlua-sys + lua-src emit `cargo:rustc-link-lib=static:-bundle=lua5.4`
# (negative-bundle = "static, don't pack into the rlib") plus a separate
# `cargo:rustc-link-search=native=$OUT_DIR/lib`. rustc serialises these into
# the final linker invocation with `-llua5.4` appearing BEFORE `-L .../out/lib`.
# Single-pass linkers like LLD process args in order and can't satisfy
# `-llua5.4` because the search path it's in hasn't been added yet — the
# entire chroot build fails with 60+ undefined-symbol errors out of mlua.
#
# `ld.bfd` does multi-pass resolution and finds the archive regardless of
# argument order, matching the behaviour of rustup's default-linker rustc
# binaries used outside Arch packaging. binutils (which provides bfd) is
# already a build-essential dep of the chroot, so this adds no install cost.
#
# Local cargo (rustup toolchain) doesn't suffer from the ordering bug, but
# pinning bfd here keeps both environments byte-identical.
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
+51 -8
View File
@@ -20,7 +20,17 @@ crates/owlry/ -- everything
│ ├── backend.rs -- SearchBackend abstraction
│ ├── app.rs -- GTK4 setup
│ ├── ui/ -- GTK widgets
│ ├── config/ -- TOML config loader
│ ├── config/ -- TOML + Lua config loader; defines LoadedConfig
│ ├── lua/ -- Lua config layer (feature: lua)
│ │ ├── api.rs -- owlry.set / providers / tabs / provider / theme / profiles
│ │ ├── config.rs -- LuaConfig accumulator + merge_into(Config)
│ │ ├── error.rs -- LuaConfigError (thiserror)
│ │ ├── migrate.rs -- TOML → owlry.lua serializer (deterministic)
│ │ ├── provider.rs -- LuaProvider impl Provider
│ │ ├── runtime.rs -- LuaContext (Arc<Lua>) + eval_file/snapshot
│ │ ├── util.rs -- owlry.util.{shell, read_file, glob, env, hostname, …}
│ │ ├── validate.rs -- ValidationReport with categorised findings
│ │ └── watcher.rs -- notify-based hot reload (desktop-notification errors)
│ ├── data/ -- FrecencyStore
│ ├── filter.rs -- ProviderFilter + prefix parser
│ ├── ipc.rs -- Request/Response types
@@ -47,27 +57,36 @@ There is no `crates/owlry-core`, `owlry-plugin-api`, `owlry-lua`, or `owlry-rune
## Build & development
```bash
# Default features (minimal — app, cmd, calc, conv, power, dmenu)
# Default features (minimal — app, cmd, calc, conv, power, dmenu, systemd)
cargo build
# Full features (matches AUR build)
# Full features (matches AUR build) — includes lua + every optional provider
cargo build --release --features full
# Lua-only opt-in on top of the default minimal set
cargo build --features lua
# Tests
cargo test --workspace --features full # 252+ tests
cargo test --workspace --no-default-features
cargo test --features full # 350+ tests including lua + watcher + migrate
cargo test --no-default-features
# Verbose dev logging
cargo run -- --features dev-logging -- -d
# Format + lint
cargo fmt --all
cargo clippy --workspace --features full
cargo clippy --features full
cargo clippy --no-default-features # must also stay silent
# Install locally (sudo)
just install-local
```
The `lua` cargo feature pulls in `mlua` (vendored Lua 5.4), `glob` (for
`owlry.util.glob`), and `notify` (for hot reload). It's part of the `full`
set; minimal builds (`cargo install` without flags) compile without it
and the `migrate-config` / Lua-load paths gracefully fall back to TOML.
The daemon binary is `owlry`. There is no separate `owlryd` binary anywhere — the daemon is `owlry -d` (or `owlry daemon`). The systemd user unit is `owlry.service` (pre-2.0 name: `owlryd.service`).
## Running locally without disturbing prod
@@ -94,13 +113,37 @@ owlry daemon run the daemon (alias: -d)
owlry dmenu [-p <prompt>] dmenu mode (reads stdin, prints selection)
owlry doctor config + socket + providers status
owlry providers [<id>] list providers (or show one)
owlry config validate parse config, report errors
owlry config validate parse config, report errors (1) and warnings (2)
owlry config show print resolved effective config
owlry migrate-config TOML → init.lua (stub; lands with Lua config)
owlry migrate-config [--force] TOML → owlry.lua (functional from 2.1; --force to overwrite)
```
The `owlry plugin ...` subcommand tree from 1.x is **gone** in 2.0. Nothing to install, manage, or run via the CLI.
## Lua config layer (2.1+)
`~/.config/owlry/owlry.lua` is the canonical config from 2.1 onwards.
`config.toml` remains supported but is ignored entirely when `owlry.lua`
exists (an info log at daemon start surfaces this). The daemon spawns a
`notify` watcher on the config dir; saves are debounced (200ms),
re-evaluated in a fresh `LuaContext`, and hot-swapped atomically. Eval
failures are kept off the live state and surfaced via both the journal
**and** a `notify-rust` desktop notification with the precise mlua
line/column. See [`docs/lua-api.md`](docs/lua-api.md) for the full API
surface (`owlry.set`, `owlry.providers`, `owlry.tabs`, `owlry.provider`,
`owlry.theme`, `owlry.profiles`, `owlry.util.*`).
Implementation notes worth keeping straight:
- `LuaContext::lua: Arc<Lua>``mlua::Function` references don't bump
the Lua refcount, so user providers hold their own `Arc<Lua>` clone
via `LuaContext::lua_handle()` to outlive the context.
- `LoadedConfig` (in `config/mod.rs`) is the resolution result that
keeps the `LuaContext` alive past `Config::load`. The daemon uses
this; `Config::load` itself drops the context after merging scalars
(so it can't be used to wire user providers — only `LoadedConfig`).
- `owlry config validate` runs `lua::validate::validate` against the
snapshot and exits 0 / 1 / 2 per `docs/lua-api.md` §8.
## Provider model
Two traits in `src/providers/mod.rs`:
Generated
+312 -14
View File
@@ -225,6 +225,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -288,7 +294,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"cairo-sys-rs",
"glib 0.21.5",
"libc",
@@ -425,6 +431,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -467,7 +482,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"objc2",
]
@@ -482,6 +497,12 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "endi"
version = "1.1.1"
@@ -538,6 +559,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -596,6 +628,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -657,6 +699,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -939,7 +990,7 @@ version = "0.20.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -960,7 +1011,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1030,6 +1081,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gobject-sys"
version = "0.20.10"
@@ -1133,7 +1190,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"gdk4",
"glib 0.21.5",
"glib-sys 0.21.5",
@@ -1462,6 +1519,26 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1530,6 +1607,26 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64"
dependencies = [
"bitflags 2.11.0",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1582,12 +1679,40 @@ dependencies = [
"winapi",
]
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lua-src"
version = "550.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb"
dependencies = [
"cc",
]
[[package]]
name = "luajit-src"
version = "210.6.6+707c12b"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295"
dependencies = [
"cc",
"which",
]
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
@@ -1624,6 +1749,18 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -1635,6 +1772,39 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mlua"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab"
dependencies = [
"bstr",
"either",
"erased-serde",
"libc",
"mlua-sys",
"num-traits",
"parking_lot",
"rustc-hash",
"rustversion",
"serde",
"serde-value",
]
[[package]]
name = "mlua-sys"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8"
dependencies = [
"cc",
"cfg-if",
"libc",
"lua-src",
"luajit-src",
"pkg-config",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -1652,6 +1822,25 @@ dependencies = [
"tempfile",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "notify-rust"
version = "4.12.0"
@@ -1716,7 +1905,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"dispatch2",
"objc2",
]
@@ -1733,7 +1922,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
@@ -1767,7 +1956,7 @@ version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
@@ -1811,6 +2000,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -1835,10 +2033,13 @@ dependencies = [
"futures-channel",
"fuzzy-matcher",
"glib-build-tools",
"glob",
"gtk4",
"gtk4-layer-shell",
"libc",
"log",
"mlua",
"notify",
"notify-rust",
"reqwest",
"serde",
@@ -1879,6 +2080,29 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link 0.2.1",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2010,6 +2234,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -2087,6 +2320,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2102,7 +2341,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
@@ -2124,6 +2363,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -2133,13 +2381,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -2172,6 +2426,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -2461,7 +2725,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.1.1",
"pin-project-lite",
"socket2",
"windows-sys 0.61.2",
@@ -2599,7 +2863,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"bytes",
"futures-util",
"http",
@@ -2660,6 +2924,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -2742,6 +3012,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -2862,7 +3142,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -2878,6 +3158,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "which"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
dependencies = [
"libc",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -2894,6 +3183,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -3281,7 +3579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.0",
"indexmap",
"log",
"serde",
+29 -6
View File
@@ -141,9 +141,9 @@ owlry -d run the daemon (alias: `owlry daemon`)
owlry dmenu [-p <prompt>] dmenu mode (reads stdin, prints selection)
owlry doctor diagnostics: config + socket + providers
owlry providers [<id>] list providers (or show details for one)
owlry config validate parse config, report errors
owlry config validate parse config, report errors (exit 1) and warnings (exit 2)
owlry config show print the resolved effective config as TOML
owlry migrate-config TOML → init.lua (stub in 2.0; lands in a later 2.x release)
owlry migrate-config [--force] TOML → owlry.lua (deterministic; refuses to overwrite without --force)
```
### Profiles
@@ -229,15 +229,37 @@ Owlry follows the [XDG Base Directory Specification](https://specifications.free
| Path | Purpose |
|------|---------|
| `~/.config/owlry/config.toml` | Main configuration |
| `~/.config/owlry/owlry.lua` | Lua configuration (preferred, takes precedence over `config.toml`) |
| `~/.config/owlry/config.toml` | TOML configuration (legacy; ignored when `owlry.lua` exists) |
| `~/.config/owlry/themes/*.css` | Custom themes |
| `~/.config/owlry/style.css` | CSS overrides |
| `~/.local/share/owlry/frecency.json` | Usage history |
| `$XDG_RUNTIME_DIR/owlry/owlry.sock` | IPC socket (overridable via `$OWLRY_SOCKET`) |
| `/usr/share/doc/owlry/config.example.toml` | Example configuration |
| `/usr/share/doc/owlry/owlry.example.lua` | Example Lua configuration |
| `/usr/share/doc/owlry/config.example.toml` | Example TOML configuration |
| `/usr/share/owlry/themes/` | Bundled themes |
### Quick Start
Config resolution order (per [`docs/lua-api.md`](docs/lua-api.md) §2): `owlry.lua``config.toml` → built-in defaults. When `owlry.lua` is present `config.toml` is ignored entirely (an info log notes this at daemon startup). The daemon watches `owlry.lua` and hot-reloads on save; broken edits surface as desktop notifications and keep the previous state alive.
### Quick Start (Lua config — preferred from 2.1+)
```bash
mkdir -p ~/.config/owlry
cp /usr/share/doc/owlry/owlry.example.lua ~/.config/owlry/owlry.lua
$EDITOR ~/.config/owlry/owlry.lua
owlry config validate
```
Already have a `config.toml`? Migrate it in one shot:
```bash
owlry migrate-config # writes owlry.lua; refuses to overwrite
owlry migrate-config --force # overwrite an existing owlry.lua
```
The migrator is deterministic, emits only values that differ from defaults, and normalises pre-v2 aliases (`system``power`, `badge_sys``badge_power`).
### Quick Start (TOML config — still supported in 2.x)
```bash
mkdir -p ~/.config/owlry
@@ -377,9 +399,10 @@ Set `OWLRY_SOCKET=/path/to/sock` to override the socket location — useful for
See [ROADMAP.md](ROADMAP.md) for feature ideas and [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md) for the v2 rewrite story.
Headline upcoming work:
- **Lua-driven configuration** (2.1 / 3.0)`~/.config/owlry/init.lua` replaces TOML. User-defined providers via `owlry.provider {}` in the same file (Hyprland-style configs-as-code). `owlry migrate-config` lands at the same time.
- **Lua-driven configuration shipped in 2.1**`~/.config/owlry/owlry.lua` is the canonical config from 2.1, with TOML kept as a back-compat fallback until 3.0. User-defined providers via `owlry.provider {}`, host helpers under `owlry.util.*`, hot reload, named profiles, theme overrides, and `owlry migrate-config` are all live. See [`docs/lua-api.md`](docs/lua-api.md) for the full surface.
- **Widget providers return** — weather, MPRIS media controls, pomodoro timer. Deferred from 2.0 while the UI positioning is reworked.
- **Bookmarks return** — Firefox + Chromium. Deferred from 2.0 to avoid a hard rusqlite/`libsqlite3-sys` dep in the chroot build path; returns with a pure-Rust reader (likely via Firefox's JSON backup files).
- **Dynamic Lua providers** (2.2) — `owlry.provider { dynamic = true }` for per-keystroke items. 2.1 ships only static (`dynamic = false`) providers.
## License
+14 -6
View File
@@ -4,14 +4,20 @@ Feature ideas and future development plans. For the v2 rewrite story (where 14 p
## Locked-in for an upcoming 2.x release
### Lua-driven configuration (Phase 3)
Replace `config.toml` with `~/.config/owlry/init.lua`. The config is real Lua, evaluated at startup via embedded `mlua` (Lua 5.4). User-defined providers, keybindings, and theme overrides all live in the same file — Hyprland-style configs-as-code. Ships with `owlry migrate-config` (TOML → init.lua) and hot-reload on save.
### Lua-driven configuration — **shipped in 2.1**
`~/.config/owlry/owlry.lua` is the canonical config from 2.1 onwards (TOML stays supported as a back-compat fallback until 3.0). Real Lua 5.4 via vendored `mlua`. The full surface is documented in [`docs/lua-api.md`](docs/lua-api.md):
- `owlry.set` / `owlry.providers` / `owlry.tabs` / `owlry.theme` / `owlry.profiles` for declarative config
- `owlry.provider {}` for user-defined providers (Hyprland-style configs-as-code)
- `owlry.util.{shell, shell_lines, read_file, glob, env, hostname}` host helpers inside provider callbacks
- Hot reload on save via the `notify` crate — broken edits surface as desktop notifications and keep the previous state live
- `owlry migrate-config [--force]` — deterministic TOML → Lua migration
- `owlry config validate` — categorised report (errors exit 1, warnings exit 2)
```lua
local owlry = require("owlry")
owlry.set { theme = "owl", width = 850, tabs = { "app", "cmd", "uuctl" } }
owlry.providers { "app", "cmd", "power", "bookmarks", "systemd" }
owlry.set { theme = "owl", width = 850 }
owlry.providers { "app", "cmd", "power", "systemd" }
owlry.tabs { "app", "cmd", "uuctl" }
owlry.provider {
id = "hs", prefix = ":hs", tab_label = "Shutdown",
@@ -19,6 +25,8 @@ owlry.provider {
}
```
**2.2 follow-ups:** dynamic (per-keystroke) Lua providers via `dynamic = true`; `owlry.bind` for runtime keybindings; `owlry.util.http_get`.
### Widget providers return
Weather, MPRIS media controls, and pomodoro timer were deferred from 2.0 while the widget-row UI is redesigned. They'll come back as a feature group once the placement model is settled. ([D20 in the v2 plan](docs/RESTRUCTURE-V2.md).)
+11
View File
@@ -86,6 +86,15 @@ build() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Force GNU ld.bfd. Arch's `extra/rust` defaults rustc to `-fuse-ld=lld`,
# and LLD's single-pass arg processing can't satisfy `-llua5.4` because
# mlua-sys+lua-src emit the `-l` flag before their `-L $OUT_DIR/lib`
# search path in the final link line (cargo:rustc-link-lib emitted
# before cargo:rustc-link-search). BFD does multi-pass and finds the
# archive regardless. RUSTFLAGS env-var beats any cargo config rustflags,
# which is necessary here because Arch's rust pkg appears to set its own
# RUSTFLAGS that we need to fully override.
export RUSTFLAGS="-C link-arg=-fuse-ld=bfd"
# 'full' enables every optional provider — the AUR binary is the
# batteries-included experience. cargo install consumers can still opt
# to --no-default-features and pick their own subset.
@@ -96,6 +105,7 @@ check() {
cd "owlry"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
export RUSTFLAGS="-C link-arg=-fuse-ld=bfd"
cargo test --frozen --release --features full
}
@@ -111,6 +121,7 @@ package() {
# Documentation + example configuration.
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 data/owlry.example.lua "$pkgdir/usr/share/doc/$pkgname/owlry.example.lua"
install -Dm644 data/config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 data/style.example.css "$pkgdir/usr/share/doc/$pkgname/style.example.css"
+27
View File
@@ -63,6 +63,26 @@ reqwest = { version = "0.13", default-features = false, features = ["native-tls"
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
# Lua 5.4 runtime for user configuration (Phase 3 — opt-in preview in 2.1).
# Vendored C source so AUR clean-chroot builds don't depend on a system Lua.
#
# NOT optional. Initial designs gated mlua/glob/notify behind `optional = true`
# so non-lua builds wouldn't pay for them, but cargo's feature resolver in
# clean chroots (`cargo build --frozen` under makepkg) silently fails to
# propagate `mlua/vendored` across the optional boundary — the build script
# ends up emitting `-llua5.4` against a non-existent system liblua. Inlining
# the features on a *non-optional* dep dodges that footgun entirely. The cost
# is a few MB of extra compile time for minimal builds; the `lua` cargo
# feature still gates whether the `lua` module is compiled at all.
mlua = { version = "0.11", features = ["lua54", "vendored", "send", "serialize"] }
# Glob pattern support for `owlry.util.glob` (Phase 3.6). Pure-Rust; small.
glob = "0.3"
# Filesystem watching for owlry.lua hot reload (Phase 3.7). On Linux the
# inotify backend is unconditional, so default features are fine.
notify = "6"
[build-dependencies]
# GResource compilation for bundled icons
glib-build-tools = "0.20"
@@ -85,6 +105,12 @@ ssh = []
systemd = []
websearch = []
# Lua config layer (Phase 3 — opt-in preview in 2.1, default in 2.2, mandatory in 3.0).
# Pure marker feature: gates the `lua` module compilation via cfg. Deps
# (mlua / glob / notify) are unconditionally pulled in to dodge the chroot
# feature-propagation footgun documented on the mlua line above.
lua = []
# Bookmarks deferred for 2.0 alongside the widget providers (D20+).
# Returns in a later 2.x release with a pure-Rust path that doesn't pull
# in libsqlite3-sys for Firefox's places.sqlite.
@@ -93,6 +119,7 @@ full = [
"clipboard",
"emoji",
"filesearch",
"lua",
"ssh",
"systemd",
"websearch",
+20 -3
View File
@@ -18,6 +18,11 @@ pub struct QueryParams {
#[allow(dead_code)]
pub max_results: usize,
pub modes: Option<Vec<String>>,
/// Provider-narrowing prefix (e.g. `"smoke"`, `"calc"`). Mirrors the
/// `prefix` field of [`crate::filter::ParsedQuery`] after `parse_query`
/// strips the `:foo ` from the entry. Daemon applies it as
/// `filter.set_prefix` before searching.
pub prefix: Option<String>,
pub tag_filter: Option<String>,
}
@@ -62,7 +67,7 @@ impl DaemonHandle {
} else {
params.query
};
match c.query(&effective_query, params.modes) {
match c.query(&effective_query, params.modes, params.prefix) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
@@ -115,6 +120,15 @@ impl SearchBackend {
}
}
/// Extract the active-prefix parameter for IPC. Mirrors the
/// `active_prefix` on the local filter so the daemon's filter can be
/// narrowed identically. `set_prefix` doesn't touch `accept_all` /
/// `enabled`, so without this the prefix would be silently lost in
/// translation (task #28).
fn build_prefix_param(filter: &ProviderFilter) -> Option<String> {
filter.active_prefix().map(|p| p.to_string())
}
/// Search for items matching the query.
///
/// In daemon mode, sends query over IPC. The modes list is derived from
@@ -131,8 +145,9 @@ impl SearchBackend {
match self {
SearchBackend::Daemon(handle) => {
let modes_param = Self::build_modes_param(filter);
let prefix_param = Self::build_prefix_param(filter);
match handle.client.lock() {
Ok(mut client) => match client.query(query, modes_param) {
Ok(mut client) => match client.query(query, modes_param, prefix_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
@@ -194,8 +209,9 @@ impl SearchBackend {
};
let modes_param = Self::build_modes_param(filter);
let prefix_param = Self::build_prefix_param(filter);
match handle.client.lock() {
Ok(mut client) => match client.query(&effective_query, modes_param) {
Ok(mut client) => match client.query(&effective_query, modes_param, prefix_param) {
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
Err(e) => {
warn!("IPC query failed: {}", e);
@@ -255,6 +271,7 @@ impl SearchBackend {
query: query.to_string(),
max_results,
modes: Self::build_modes_param(filter),
prefix: Self::build_prefix_param(filter),
tag_filter: tag_filter.map(|s| s.to_string()),
};
Some(handle.query_async(params))
+26 -4
View File
@@ -24,7 +24,7 @@ EXAMPLES:
owlry providers [<id>] List providers (or show one)
owlry config validate Check config for errors
owlry config show Print resolved effective config
owlry migrate-config TOML → init.lua (Phase 3+; stub for now)
owlry migrate-config [--force] TOML → owlry.lua (--force to overwrite)
SEARCH PREFIXES (inside the UI):
:app firefox Search applications
@@ -87,8 +87,13 @@ pub enum Command {
action: ConfigAction,
},
/// Migrate TOML config to init.lua. Stub in 2.0; lands with Phase 3 (Lua config).
MigrateConfig,
/// Migrate `config.toml` to `owlry.lua`. Refuses to overwrite an existing
/// `owlry.lua` unless `--force` is passed. See `docs/lua-api.md` §9.
MigrateConfig {
/// Overwrite an existing `owlry.lua` if it's already on disk.
#[arg(long, short = 'f')]
force: bool,
},
}
#[derive(Subcommand, Debug, Clone)]
@@ -241,6 +246,23 @@ mod tests {
#[test]
fn migrate_config_subcommand_parses() {
let args = CliArgs::try_parse_from(["owlry", "migrate-config"]).unwrap();
assert!(matches!(args.command, Some(Command::MigrateConfig)));
assert!(matches!(
args.command,
Some(Command::MigrateConfig { force: false })
));
}
#[test]
fn migrate_config_accepts_force_flag() {
let args = CliArgs::try_parse_from(["owlry", "migrate-config", "--force"]).unwrap();
assert!(matches!(
args.command,
Some(Command::MigrateConfig { force: true })
));
let args = CliArgs::try_parse_from(["owlry", "migrate-config", "-f"]).unwrap();
assert!(matches!(
args.command,
Some(Command::MigrateConfig { force: true })
));
}
}
+84 -3
View File
@@ -116,10 +116,21 @@ impl CoreClient {
}
/// Send a search query and return matching results.
pub fn query(&mut self, text: &str, modes: Option<Vec<String>>) -> io::Result<Vec<ResultItem>> {
///
/// `prefix` (when `Some`) narrows the daemon-side search to a single
/// provider — typically the `prefix` field from
/// [`crate::filter::ProviderFilter::parse_query`]. Pass `None` for the
/// unrestricted case (the daemon falls back to `modes` or accept-all).
pub fn query(
&mut self,
text: &str,
modes: Option<Vec<String>>,
prefix: Option<String>,
) -> io::Result<Vec<ResultItem>> {
self.send(&Request::Query {
text: text.to_string(),
modes,
prefix,
})?;
match self.receive()? {
@@ -245,6 +256,38 @@ mod tests {
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Spawn a mock server that accepts one connection, captures the request,
/// and replies with the given canned response. Returns the socket path and
/// a Receiver that will yield the request line after the test sends.
fn mock_server_capturing(response: Response) -> (PathBuf, std::sync::mpsc::Receiver<String>) {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("owlry-test-{}-{}", std::process::id(), n));
let _ = std::fs::create_dir_all(&dir);
let sock = dir.join("test.sock");
let _ = std::fs::remove_file(&sock);
let listener = UnixListener::bind(&sock).expect("bind mock socket");
let sock_clone = sock.clone();
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
reader.read_line(&mut line).expect("read request");
let _ = tx.send(line);
let mut json = serde_json::to_string(&response).unwrap();
json.push('\n');
writer.write_all(json.as_bytes()).unwrap();
writer.flush().unwrap();
let _ = std::fs::remove_file(&sock_clone);
let _ = std::fs::remove_dir(dir);
});
(sock, rx)
}
/// Spawn a mock server that accepts one connection, reads one request,
/// and replies with the given canned response. Each call gets a unique
/// socket path to avoid collisions when tests run in parallel.
@@ -303,7 +346,7 @@ mod tests {
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let results = client.query("fire", None).expect("query");
let results = client.query("fire", None, None).expect("query");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "firefox");
@@ -311,6 +354,44 @@ mod tests {
assert_eq!(results[0].score, 100);
}
#[test]
fn query_sends_prefix_field_in_request() {
let canned = Response::Results { items: vec![] };
let (sock, rx) = mock_server_capturing(canned);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let _ = client
.query("foo", None, Some("smoke".into()))
.expect("query");
let request_line = rx.recv_timeout(Duration::from_secs(2)).expect("captured");
let parsed: serde_json::Value = serde_json::from_str(&request_line).expect("parse json");
assert_eq!(parsed["type"], "query");
assert_eq!(parsed["text"], "foo");
assert_eq!(
parsed["prefix"], "smoke",
"prefix must be present in the request"
);
}
#[test]
fn query_without_prefix_omits_field_from_request() {
let canned = Response::Results { items: vec![] };
let (sock, rx) = mock_server_capturing(canned);
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let _ = client.query("foo", None, None).expect("query");
let request_line = rx.recv_timeout(Duration::from_secs(2)).expect("captured");
assert!(
!request_line.contains("prefix"),
"prefix field must be omitted when None; got: {}",
request_line
);
}
#[test]
fn toggle_returns_ack() {
let sock = mock_server(Response::Ack);
@@ -392,7 +473,7 @@ mod tests {
thread::sleep(Duration::from_millis(50));
let mut client = CoreClient::connect(&sock).expect("connect");
let err = client.query("test", None).unwrap_err();
let err = client.query("test", None, None).unwrap_err();
let msg = err.to_string();
assert!(
+384 -9
View File
@@ -33,6 +33,107 @@ pub fn run_daemon() -> ! {
}
}
/// Print which config source is active (Lua / TOML / defaults) and nudge
/// the user toward the canonical 2.1+ format when they're still on TOML.
/// Also surfaces pre-v2 plugin / scripts artifacts in the same pass so the
/// user sees everything in one place rather than only on `migrate-config`.
fn print_config_source_info(out: &mut impl Write) {
let lua_path = paths::lua_config_file();
let toml_path = paths::config_file();
let lua_exists = lua_path.as_deref().is_some_and(|p| p.exists());
let toml_exists = toml_path.as_deref().is_some_and(|p| p.exists());
#[cfg(feature = "lua")]
{
if lua_exists {
let _ = writeln!(
out,
" source: Lua ({})",
lua_path.as_ref().unwrap().display()
);
if toml_exists {
let _ = writeln!(
out,
" note: {} is present alongside owlry.lua — ignored.",
toml_path.as_ref().unwrap().display()
);
}
} else if toml_exists {
let _ = writeln!(
out,
" source: TOML ({})",
toml_path.as_ref().unwrap().display()
);
let _ = writeln!(
out,
" hint: `owlry migrate-config` rewrites it as owlry.lua, the canonical"
);
let _ = writeln!(
out,
" format from 2.1 onwards. TOML works until 3.0; the migrator is"
);
let _ = writeln!(out, " deterministic and pass `--force` to overwrite.");
} else {
let _ = writeln!(out, " source: built-in defaults (no user config found)");
}
// Legacy plugin/scripts artifacts — same scan migrate-config uses.
if let Some(toml_p) = toml_path.as_deref() {
let config_dir = toml_p.parent().unwrap_or_else(|| std::path::Path::new("."));
let artifacts = crate::lua::migrate::scan_legacy_artifacts(config_dir);
if !artifacts.is_empty() {
let _ = writeln!(out);
let _ = writeln!(
out,
" legacy: {} pre-v2 {} detected in your config dir.",
artifacts.len(),
if artifacts.len() == 1 { "artifact" } else { "artifacts" }
);
for a in &artifacts {
use crate::lua::migrate::LegacyKind;
match &a.kind {
LegacyKind::Plugin { id, .. } => {
let _ = writeln!(out, " - plugin `{}` at {}", id, a.path.display());
}
LegacyKind::ScriptsDir { entries } => {
let _ = writeln!(
out,
" - scripts dir ({} {}) at {}",
entries.len(),
if entries.len() == 1 { "entry" } else { "entries" },
a.path.display()
);
}
}
}
let _ = writeln!(
out,
" Run `owlry migrate-config` for paste-ready owlry.provider {{}} skeletons."
);
}
}
}
#[cfg(not(feature = "lua"))]
{
if toml_exists {
let _ = writeln!(
out,
" source: TOML ({})",
toml_path.as_ref().unwrap().display()
);
let _ = writeln!(
out,
" note: this build was compiled without the `lua` feature — owlry.lua is ignored."
);
} else {
let _ = writeln!(out, " source: built-in defaults (no user config found)");
}
let _ = lua_exists; // suppress unused-var on non-lua builds
}
}
/// `owlry doctor`: print the daemon socket status, loaded provider list, and
/// config status. Useful for confirming an install is wired correctly.
pub fn run_doctor() -> ! {
@@ -48,6 +149,7 @@ pub fn run_doctor() -> ! {
match Config::load() {
Ok(_) => {
let _ = writeln!(out, " OK");
print_config_source_info(&mut out);
}
Err(e) => {
let _ = writeln!(out, " ERROR: {e}");
@@ -146,11 +248,34 @@ fn print_provider_list(out: &mut impl Write, list: &[ProviderDesc]) {
}
}
/// `owlry config validate`: parse the config file and report errors.
/// `owlry config validate`: parse the config file and report errors and
/// warnings per docs/lua-api.md §8.
///
/// Resolution order matches `Config::load`:
/// 1. If `owlry.lua` exists, evaluate it. Eval errors → exit 1. Otherwise
/// run [`crate::lua::validate::validate`] on the snapshot and surface
/// every warning category (unknown set/theme keys, unknown provider
/// ids, tabs ⊄ providers, duplicate provider ids, compiled-out
/// providers, unknown ids inside profiles).
/// 2. Otherwise fall back to the TOML check.
///
/// Exit codes: 0 clean, 1 errors (eval / TOML parse failure), 2
/// warnings-only.
pub fn run_config_validate() -> ! {
#[cfg(feature = "lua")]
{
if let Some(lua_path) = paths::lua_config_file()
&& lua_path.exists()
{
let exit = run_config_validate_lua(&lua_path);
std::process::exit(exit);
}
}
// No owlry.lua → fall back to TOML.
match Config::load() {
Ok(_) => {
println!("config: OK");
println!("config: OK (TOML)");
std::process::exit(0);
}
Err(e) => {
@@ -160,6 +285,57 @@ pub fn run_config_validate() -> ! {
}
}
#[cfg(feature = "lua")]
fn run_config_validate_lua(lua_path: &std::path::Path) -> i32 {
use crate::config::LoadedConfig;
use crate::lua::validate;
let loaded = match LoadedConfig::load_lua_path(lua_path) {
Ok(lc) => lc,
Err(e) => {
// Eval failure — report the chain (mlua error has line/col info).
let mut msg = e.to_string();
let mut cur: Option<&dyn std::error::Error> = e.source();
while let Some(c) = cur {
msg.push_str(": ");
msg.push_str(&c.to_string());
cur = c.source();
}
eprintln!("config: ERROR — {}", msg);
return 1;
}
};
let snapshot = match loaded.lua.as_ref() {
Some(ctx) => ctx.snapshot(),
None => {
eprintln!("config: ERROR — internal: Lua context missing after load");
return 1;
}
};
let report = validate::validate(&snapshot);
if report.is_clean() {
println!("config: OK (Lua, {})", lua_path.display());
return 0;
}
println!("config: validating {}", lua_path.display());
if !report.warnings.is_empty() {
println!(" {} warning(s):", report.warnings.len());
for w in &report.warnings {
println!(" - {}", w);
}
}
if !report.errors.is_empty() {
println!(" {} error(s):", report.errors.len());
for e in &report.errors {
println!(" - {}", e);
}
}
report.exit_code()
}
/// `owlry config show`: serialize the effective config to stdout as TOML.
pub fn run_config_show() -> ! {
let cfg = Config::load_or_default();
@@ -182,14 +358,213 @@ pub fn run_config(action: ConfigAction) -> ! {
}
}
/// `owlry migrate-config`: TOML → init.lua. Lands in Phase 3 (Lua config).
pub fn run_migrate_config() -> ! {
eprintln!(
"migrate-config: not yet implemented.\n\
The Lua config layer lands in 2.1+ (or 3.0). TOML config remains the\n\
active format in 2.0. See docs/RESTRUCTURE-V2.md (phase 3) for status."
/// `owlry migrate-config [--force]`: TOML → `owlry.lua`.
///
/// Reads `$XDG_CONFIG_HOME/owlry/config.toml`, parses it (pre-v2 aliases
/// like `system` / `badge_sys` normalise to v2 names via the existing
/// serde aliases), and writes `$XDG_CONFIG_HOME/owlry/owlry.lua` with
/// the equivalent settings. Output is deterministic and minimal —
/// values matching `Config::default()` are omitted. See
/// `docs/lua-api.md` §9 for the mapping table.
///
/// Builds without `--features lua` fall through to a clear stub: the
/// migration generates Lua code, which is only meaningful when the
/// runtime is present.
pub fn run_migrate_config(force: bool) -> ! {
#[cfg(feature = "lua")]
{
let exit_code = run_migrate_config_impl(force);
std::process::exit(exit_code);
}
#[cfg(not(feature = "lua"))]
{
let _ = force;
eprintln!(
"migrate-config: this owlry was built without the `lua` feature.\n\
Rebuild with `cargo build --features lua` (or `--features full`)\n\
to use the migrator. TOML config remains the active format until\n\
3.0; see docs/lua-api.md §11."
);
std::process::exit(2);
}
}
#[cfg(feature = "lua")]
fn run_migrate_config_impl(force: bool) -> i32 {
let toml_path = match paths::config_file() {
Some(p) => p,
None => {
eprintln!("migrate-config: cannot resolve $XDG_CONFIG_HOME/owlry/config.toml");
return 2;
}
};
let lua_path = match paths::lua_config_file() {
Some(p) => p,
None => {
eprintln!("migrate-config: cannot resolve $XDG_CONFIG_HOME/owlry/owlry.lua");
return 2;
}
};
let req = crate::lua::MigrateRequest {
toml_path: &toml_path,
lua_path: &lua_path,
force,
};
match crate::lua::migrate::migrate(&req) {
Ok(out) => {
println!(
"owlry migrate-config: wrote {} ({} bytes) from {}",
out.lua_path.display(),
out.bytes_written,
toml_path.display(),
);
println!(
"owlry.lua now takes precedence over config.toml (see docs/lua-api.md §2)."
);
report_legacy_artifacts(&out.legacy_artifacts);
0
}
Err(crate::lua::MigrateError::DestExists(_)) => {
eprintln!(
"migrate-config: {} already exists.\n\
Pass --force to overwrite, or delete it first.",
lua_path.display()
);
1
}
Err(crate::lua::MigrateError::NoToml(_)) => {
eprintln!(
"migrate-config: no TOML config at {}.\n\
Nothing to migrate — write an owlry.lua directly, or\n\
copy /usr/share/doc/owlry/config.example.toml as a starting point.",
toml_path.display()
);
1
}
Err(e) => {
eprintln!("migrate-config: {}", e);
2
}
}
}
/// Print a heads-up block for each pre-v2 artifact the migrator detected
/// in the config dir. Plugins (Rune / C-ABI) and the `scripts/` directory
/// can't be auto-translated — the user has to recreate them as
/// `owlry.provider {}` entries inside `owlry.lua`. We surface what we
/// found, including the original prefix / icon when known, so the user
/// has a paste-able starting point.
#[cfg(feature = "lua")]
fn report_legacy_artifacts(artifacts: &[crate::lua::migrate::LegacyArtifact]) {
if artifacts.is_empty() {
return;
}
use crate::lua::migrate::LegacyKind;
println!();
println!(
"Heads up: detected {} pre-v2 {} in your config dir that the migrator",
artifacts.len(),
if artifacts.len() == 1 { "artifact" } else { "artifacts" }
);
println!(
"did NOT translate. They aren't loaded by 2.1+ — they're just files sitting"
);
println!("there from your 1.x install. Recreate them in owlry.lua if you still use them:");
for artifact in artifacts {
println!();
match &artifact.kind {
LegacyKind::Plugin {
id,
name,
prefix,
icon,
entry_point,
} => {
println!(" ── plugin: {} ─────────────────────────────────", id);
println!(" path: {}", artifact.path.display());
if let Some(n) = name {
println!(" name: {}", n);
}
if let Some(p) = prefix {
println!(" prefix: {}", p);
}
if let Some(i) = icon {
println!(" icon: {}", i);
}
if let Some(ep) = entry_point {
println!(" entry_point: {} (Rune / C-ABI; unsupported in 2.1)", ep);
}
println!(" ──");
println!(" Recreate as a user provider in owlry.lua:");
println!();
println!(" owlry.provider {{");
println!(" id = \"{}\",", id);
if let Some(p) = prefix {
println!(" prefix = \"{}\",", p);
}
if let Some(i) = icon {
println!(" icon = \"{}\",", i);
}
println!(" items = function()");
println!(
" return {{ -- translate {} here",
entry_point.as_deref().unwrap_or("the plugin")
);
println!(" -- {{ name = \"\", command = \"\" }},");
println!(" }}");
println!(" end,");
println!(" }}");
}
LegacyKind::ScriptsDir { entries } => {
println!(" ── legacy `scripts/` directory ─────────────────");
println!(" path: {}", artifact.path.display());
println!(
" {} {}: {}",
entries.len(),
if entries.len() == 1 { "file" } else { "files" },
if entries.is_empty() {
"(empty)".to_string()
} else {
entries.join(", ")
}
);
println!(" ──");
println!(
" The standalone `scripts` provider was removed in 2.0. Wrap each"
);
println!(" script in an `owlry.provider {{}}` block, e.g.:");
println!();
println!(" owlry.provider {{");
println!(" id = \"my-scripts\",");
println!(" prefix = \":sh\",");
println!(" items = function()");
println!(" local items = {{}}");
println!(
" for _, name in ipairs(owlry.util.shell_lines(\"ls {}\")) do",
artifact.path.display()
);
println!(" table.insert(items, {{");
println!(" name = name,");
println!(
" command = \"{}/\" .. name,",
artifact.path.display()
);
println!(" }})");
println!(" end");
println!(" return items");
println!(" end,");
println!(" }}");
}
}
}
println!();
println!(
" See `docs/lua-api.md` §4.4 (user providers) and §5.1 (owlry.util.*)."
);
std::process::exit(2);
}
/// `owlry dmenu`: delegate to the UI's dmenu pipeline.
+372 -46
View File
@@ -2,7 +2,7 @@ use fs2::FileExt;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::paths;
@@ -498,59 +498,87 @@ impl Config {
})
}
/// Resolve the user's effective config. Order per `docs/lua-api.md` §2:
///
/// 1. `$XDG_CONFIG_HOME/owlry/owlry.lua` — when present, defaults are the
/// base and Lua overlays. TOML is ignored entirely with an info log.
/// 2. `$XDG_CONFIG_HOME/owlry/config.toml` — back-compat path.
/// 3. Built-in defaults.
///
/// The terminal command is auto-detected at the end regardless of source.
///
/// Lua-defined user providers from `owlry.provider {}` are dropped here
/// (the [`crate::lua::LuaContext`] is released after merge). Callers that
/// need them — the daemon, hot-reload — must use [`LoadedConfig::load`]
/// instead, which keeps the context alive.
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
let mut config = if !path.exists() {
info!("Config file not found, using defaults");
Self::default()
} else {
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Migrate legacy [plugins.<name>] entries to [plugin_config.<name>].
// Known PluginsConfig fields are excluded from migration.
const KNOWN_PLUGINS_KEYS: &[&str] =
&["enabled", "disabled_plugins", "sandbox", "registry_url"];
if let Ok(raw) = toml::from_str::<toml::Value>(&content)
&& let Some(plugins_table) = raw.get("plugins").and_then(|v| v.as_table())
#[cfg(feature = "lua")]
{
if let Some(lua_path) = paths::lua_config_file()
&& lua_path.exists()
{
for (key, value) in plugins_table {
if !KNOWN_PLUGINS_KEYS.contains(&key.as_str())
&& !config.plugin_config.contains_key(key)
{
warn!(
"Config: [plugins.{}] is deprecated; move to [plugin_config.{}]",
key, key
);
config.plugin_config.insert(key.clone(), value.clone());
}
}
}
config
};
// Auto-detect terminal if not configured or configured terminal doesn't exist
match &config.general.terminal_command {
None => {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) if !command_exists(term) => {
warn!("Configured terminal '{}' not found, auto-detecting", term);
let terminal = detect_terminal();
info!("Using detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
let mut cfg = Self::load_via_lua(&lua_path)?;
detect_and_apply_terminal(&mut cfg);
return Ok(cfg);
}
}
let mut cfg = Self::load_from_toml(Self::config_path().as_deref())?;
detect_and_apply_terminal(&mut cfg);
Ok(cfg)
}
/// TOML-only load path. Honours the legacy `[plugins.<name>]` →
/// `[plugin_config.<name>]` migration. Returns defaults if the file is
/// missing or the path is `None`.
pub(crate) fn load_from_toml(
path: Option<&Path>,
) -> Result<Self, Box<dyn std::error::Error>> {
let Some(path) = path else {
info!("No TOML config path available; using defaults");
return Ok(Self::default());
};
if !path.exists() {
info!("Config file not found, using defaults");
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Migrate legacy [plugins.<name>] entries to [plugin_config.<name>].
// Known PluginsConfig fields are excluded from migration.
const KNOWN_PLUGINS_KEYS: &[&str] =
&["enabled", "disabled_plugins", "sandbox", "registry_url"];
if let Ok(raw) = toml::from_str::<toml::Value>(&content)
&& let Some(plugins_table) = raw.get("plugins").and_then(|v| v.as_table())
{
for (key, value) in plugins_table {
if !KNOWN_PLUGINS_KEYS.contains(&key.as_str())
&& !config.plugin_config.contains_key(key)
{
warn!(
"Config: [plugins.{}] is deprecated; move to [plugin_config.{}]",
key, key
);
config.plugin_config.insert(key.clone(), value.clone());
}
}
}
Ok(config)
}
/// Evaluate `owlry.lua` and overlay it onto defaults. TOML is ignored
/// per spec §2. Drops the LuaContext immediately — user providers won't
/// survive past this call (use [`LoadedConfig::load`] for those).
#[cfg(feature = "lua")]
fn load_via_lua(lua_path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let loaded = LoadedConfig::load_lua_path(lua_path)?;
Ok(loaded.config)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
@@ -596,6 +624,147 @@ impl Config {
}
}
/// Auto-detect and apply the terminal command on a [`Config`]. Runs after
/// every load path (Lua, TOML, defaults) so the effective config always has
/// a working terminal — even when neither file specified one.
fn detect_and_apply_terminal(config: &mut Config) {
match &config.general.terminal_command {
None => {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) if !command_exists(term) => {
warn!("Configured terminal '{}' not found, auto-detecting", term);
let terminal = detect_terminal();
info!("Using detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
}
}
}
// =============================================================================
// LoadedConfig — full resolution result with the Lua runtime kept alive
// =============================================================================
//
// Read this before touching the type: the daemon needs the LuaContext to
// outlive the user-defined providers it captured (their `items` closures
// reference Lua state). [`Config::load`] discards the context after merging
// scalars; daemon and hot-reload paths must use [`LoadedConfig::load`] to
// hold it open.
/// Full result of config resolution: the effective [`Config`] plus the
/// optional Lua runtime + captured user-provider specs.
///
/// Behaviour rules:
/// - `lua` and `lua_path` are `Some` only when `owlry.lua` was found and
/// evaluated successfully. Otherwise both are `None` and `user_providers`
/// is empty.
/// - When `lua` is present, the base [`Config`] is built from defaults and
/// overlaid with Lua state — TOML is intentionally ignored (spec §2).
/// An info log is emitted if a `config.toml` is sitting next to it.
#[cfg(feature = "lua")]
pub struct LoadedConfig {
pub config: Config,
pub lua: Option<crate::lua::LuaContext>,
pub lua_path: Option<PathBuf>,
pub user_providers: Vec<crate::lua::LuaProviderSpec>,
}
#[cfg(feature = "lua")]
impl std::fmt::Debug for LoadedConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedConfig")
.field("config", &"<Config>")
.field("lua", &self.lua.as_ref().map(|_| "<LuaContext>"))
.field("lua_path", &self.lua_path)
.field("user_providers", &self.user_providers)
.finish()
}
}
#[cfg(feature = "lua")]
impl LoadedConfig {
/// Build a [`LoadedConfig`] containing only defaults + an auto-detected
/// terminal. Useful for tests and the `load_or_default` fallback.
pub fn defaults() -> Self {
let mut config = Config::default();
detect_and_apply_terminal(&mut config);
Self {
config,
lua: None,
lua_path: None,
user_providers: Vec::new(),
}
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_else(|e| {
warn!("Failed to load config: {}; using defaults", e);
Self::defaults()
})
}
/// Resolve via the full Lua → TOML → defaults order. See
/// [`Config::load`] for the same precedence semantics; the difference
/// is that this variant preserves the Lua runtime and user providers.
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
if let Some(lua_path) = paths::lua_config_file()
&& lua_path.exists()
{
return Self::load_lua_path(&lua_path);
}
// No Lua config — fall back to TOML or defaults.
let mut config = Config::load_from_toml(Config::config_path().as_deref())?;
detect_and_apply_terminal(&mut config);
Ok(Self {
config,
lua: None,
lua_path: None,
user_providers: Vec::new(),
})
}
/// Internal: evaluate a specific Lua file and produce a LoadedConfig.
/// Used by both the public load path and [`Config::load_via_lua`].
pub(crate) fn load_lua_path(lua_path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
// If a TOML config sits alongside the Lua file, surface that we're
// ignoring it. Per spec §2: "TOML is ignored entirely when Lua is
// present." A 2.2 release will upgrade this to a warning; 3.0
// removes TOML support entirely.
if let Some(toml_path) = paths::config_file()
&& toml_path.exists()
{
info!(
"owlry.lua present at {:?} — ignoring {:?} (Lua takes precedence)",
lua_path, toml_path
);
}
let ctx = crate::lua::LuaContext::new()?;
ctx.eval_file(lua_path)?;
let lua_cfg = ctx.snapshot();
let mut config = Config::default();
lua_cfg.merge_into(&mut config);
detect_and_apply_terminal(&mut config);
info!("Loaded Lua config from {:?}", lua_path);
Ok(Self {
config,
lua: Some(ctx),
lua_path: Some(lua_path.to_path_buf()),
user_providers: lua_cfg.user_providers,
})
}
}
#[cfg(test)]
mod tests {
#[test]
@@ -647,3 +816,160 @@ badge_sys = "#ff8800"
);
}
}
#[cfg(all(test, feature = "lua"))]
mod loaded_config_tests {
//! Tests for the Lua-aware config resolver. Each test writes a temp file
//! and feeds the exact path through [`LoadedConfig::load_lua_path`] or
//! [`Config::load_from_toml`] to avoid touching the real XDG config dir.
use super::*;
use std::io::Write;
fn write_file(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(contents.as_bytes()).expect("write");
path
}
#[test]
fn defaults_apply_when_no_files_exist() {
let lc = LoadedConfig::defaults();
assert!(lc.lua.is_none());
assert!(lc.lua_path.is_none());
assert!(lc.user_providers.is_empty());
// Terminal detection should always fill this in.
assert!(lc.config.general.terminal_command.is_some());
}
#[test]
fn lua_path_overrides_defaults_with_set_values() {
let dir = tempfile::tempdir().expect("tempdir");
let lua = write_file(
dir.path(),
"owlry.lua",
r#"owlry.set { theme = "nord", width = 1024, max_results = 25 }"#,
);
let lc = LoadedConfig::load_lua_path(&lua).expect("load");
assert_eq!(lc.config.appearance.theme.as_deref(), Some("nord"));
assert_eq!(lc.config.appearance.width, 1024);
assert_eq!(lc.config.general.max_results, 25);
assert!(lc.lua.is_some(), "LuaContext must be retained");
assert_eq!(lc.lua_path.as_deref(), Some(lua.as_path()));
assert!(lc.user_providers.is_empty());
}
#[test]
fn lua_providers_list_disables_unlisted_built_ins() {
let dir = tempfile::tempdir().expect("tempdir");
let lua = write_file(
dir.path(),
"owlry.lua",
r#"owlry.providers { "app", "cmd" }"#, // power, ssh, emoji etc. → off
);
let lc = LoadedConfig::load_lua_path(&lua).expect("load");
assert!(lc.config.providers.applications);
assert!(lc.config.providers.commands);
assert!(!lc.config.providers.power);
assert!(!lc.config.providers.ssh);
assert!(!lc.config.providers.systemd);
}
#[test]
fn lua_user_provider_is_captured() {
let dir = tempfile::tempdir().expect("tempdir");
let lua = write_file(
dir.path(),
"owlry.lua",
r#"
owlry.provider {
id = "hs",
prefix = ":hs",
items = function() return { { name = "Lock", command = "hyprlock" } } end,
}
"#,
);
let lc = LoadedConfig::load_lua_path(&lua).expect("load");
assert_eq!(lc.user_providers.len(), 1);
assert_eq!(lc.user_providers[0].id, "hs");
assert_eq!(lc.user_providers[0].prefix.as_deref(), Some(":hs"));
}
#[test]
fn lua_provider_remains_callable_after_load() {
// End-to-end: a LuaProvider built from a LoadedConfig must still be
// able to call its items function once the spec is wired up. This
// is the integration that 3.4 unlocks.
use crate::lua::LuaProvider;
use crate::providers::Provider;
let dir = tempfile::tempdir().expect("tempdir");
let lua = write_file(
dir.path(),
"owlry.lua",
r#"
owlry.provider {
id = "hs",
items = function()
return {
{ name = "Lock", command = "hyprlock" },
{ name = "Shutdown", command = "systemctl poweroff" },
}
end,
}
"#,
);
let lc = LoadedConfig::load_lua_path(&lua).expect("load");
let ctx = lc.lua.as_ref().expect("lua context");
let mut p = LuaProvider::new(lc.user_providers[0].clone(), ctx.lua_handle());
p.refresh();
let items = p.items();
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "Lock");
assert_eq!(items[1].name, "Shutdown");
}
#[test]
fn lua_syntax_error_is_surfaced() {
let dir = tempfile::tempdir().expect("tempdir");
let lua = write_file(dir.path(), "owlry.lua", "this is not valid lua $$$");
let err = LoadedConfig::load_lua_path(&lua).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("Lua") || msg.contains("evaluation"),
"should mention Lua failure; got: {}",
msg
);
}
#[test]
fn toml_only_load_returns_defaults_when_path_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("does-not-exist.toml");
let cfg = Config::load_from_toml(Some(&missing)).expect("load");
// Defaults — terminal NOT auto-detected here (helper is internal).
assert!(cfg.providers.applications);
assert!(cfg.providers.power);
}
#[test]
fn toml_only_load_parses_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let toml = write_file(
dir.path(),
"config.toml",
r#"
[providers]
power = false
ssh = false
"#,
);
let cfg = Config::load_from_toml(Some(&toml)).expect("load");
assert!(!cfg.providers.power);
assert!(!cfg.providers.ssh);
// Untouched keys keep their defaults.
assert!(cfg.providers.applications);
}
}
+33
View File
@@ -483,6 +483,39 @@ mod tests {
assert_eq!(result.query, "5+3");
}
#[test]
fn parse_query_routes_unknown_prefix_to_plugin_type_id() {
// Locks in the dynamic-prefix-fallback path (filter.rs:319-347): a
// prefix not in the hardcoded core/plugin tables — e.g. a user
// provider's `:smoke` — must still produce Plugin("smoke"). This is
// what makes user-defined providers reachable by prefix.
let result = ProviderFilter::parse_query(":smoke item");
assert_eq!(
result.prefix,
Some(ProviderType::Plugin("smoke".to_string()))
);
assert_eq!(result.query, "item");
let bare = ProviderFilter::parse_query(":smoke");
assert_eq!(
bare.prefix,
Some(ProviderType::Plugin("smoke".to_string()))
);
assert_eq!(bare.query, "");
}
#[test]
fn build_prefix_param_round_trips_through_provider_type() {
// The wire format is ProviderType's Display impl; the daemon parses
// it back via FromStr. Verify both ends agree for a user provider.
use std::str::FromStr;
let plugin_smoke = ProviderType::Plugin("smoke".to_string());
let wire = plugin_smoke.to_string();
assert_eq!(wire, "smoke");
let parsed = ProviderType::from_str(&wire).unwrap();
assert_eq!(parsed, plugin_smoke);
}
#[test]
fn test_toggle_ensures_one_enabled() {
let mut filter = ProviderFilter::apps_only();
+58
View File
@@ -7,6 +7,15 @@ pub enum Request {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
modes: Option<Vec<String>>,
/// Active prefix narrowing — the daemon restricts results to this
/// single provider. Carries the [`ProviderType`] as a string
/// (e.g. `"app"`, `"cmd"`, `"smoke"` for a user-defined provider
/// with id `"smoke"`). The UI populates this from
/// `ProviderFilter::parse_query`'s `prefix` field after stripping
/// the `:foo ` from the query text. Older clients omit the field;
/// the daemon treats `None` as "no narrowing".
#[serde(default, skip_serializing_if = "Option::is_none")]
prefix: Option<String>,
},
Launch {
item_id: String,
@@ -73,3 +82,52 @@ pub struct ProviderDesc {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_noun: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_request_without_prefix_serializes_without_field() {
// Pre-3.4.5 clients didn't have the field; new schema must omit it
// from JSON when None so on-wire payloads stay identical.
let req = Request::Query {
text: "firefox".into(),
modes: None,
prefix: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("prefix"), "prefix must be omitted; got: {}", json);
assert!(json.contains(r#""type":"query""#));
assert!(json.contains(r#""text":"firefox""#));
}
#[test]
fn query_request_with_prefix_serializes_and_roundtrips() {
let req = Request::Query {
text: "foo".into(),
modes: None,
prefix: Some("smoke".into()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains(r#""prefix":"smoke""#));
let back: Request = serde_json::from_str(&json).unwrap();
assert_eq!(back, req);
}
#[test]
fn legacy_clients_without_prefix_field_still_deserialize() {
// A 2.0.x client sends Query without the new prefix field. The 2.1
// daemon must still accept it (default = None).
let json = r#"{"type":"query","text":"foo"}"#;
let req: Request = serde_json::from_str(json).unwrap();
match req {
Request::Query { text, modes, prefix } => {
assert_eq!(text, "foo");
assert!(modes.is_none());
assert!(prefix.is_none());
}
other => panic!("expected Query, got {:?}", other),
}
}
}
+2
View File
@@ -13,6 +13,8 @@ pub mod config;
pub mod data;
pub mod filter;
pub mod ipc;
#[cfg(feature = "lua")]
pub mod lua;
pub mod notify;
pub mod paths;
pub mod providers;
+381
View File
@@ -0,0 +1,381 @@
//! `owlry.*` Lua API surface — table construction and function registration.
//!
//! Phase 3.2 installed three functions:
//! - `owlry.set(table)` — scalar overrides (last-write-wins per key)
//! - `owlry.providers(list)` — enabled provider list (replaces on call)
//! - `owlry.tabs(list)` — tab order list (replaces on call)
//!
//! Phase 3.3 added:
//! - `owlry.provider(table)` — register a user-defined provider
//!
//! Phase 3.5 adds:
//! - `owlry.theme(name_or_table)` — theme name and/or inline colour overrides
//! - `owlry.profiles(table)` — named provider-id sets for `--profile`
//!
//! Phase 3.6 adds the `owlry.util.*` sub-table (shell, read_file, glob, env,
//! hostname). Built by [`super::util::build`] and attached here.
//!
//! Each closure shares an `Arc<Mutex<LuaConfig>>` with [`super::runtime`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::warn;
use mlua::{Function, Lua, Table, Value};
use super::config::{
KNOWN_SET_KEYS, KNOWN_THEME_KEYS, LuaConfig, LuaProviderSpec, is_valid_provider_id,
};
use super::error::LuaConfigError;
/// Build the `owlry` table, register all functions on it, install it as a
/// global, and make `require("owlry")` return the same table.
pub(crate) fn install(lua: &Lua, state: Arc<Mutex<LuaConfig>>) -> Result<(), LuaConfigError> {
let owlry = lua.create_table()?;
register_set(lua, &owlry, Arc::clone(&state))?;
register_providers(lua, &owlry, Arc::clone(&state))?;
register_tabs(lua, &owlry, Arc::clone(&state))?;
register_provider(lua, &owlry, Arc::clone(&state))?;
register_theme(lua, &owlry, Arc::clone(&state))?;
register_profiles(lua, &owlry, Arc::clone(&state))?;
// owlry.util.* host helpers — stateless, no LuaConfig access needed.
owlry.set("util", super::util::build(lua)?)?;
lua.globals().set("owlry", owlry)?;
// Make `require("owlry")` resolve to the global table. Done in Lua to
// avoid the closure-Send dance around moving a Table into a function.
lua.load(r#"package.preload["owlry"] = function() return owlry end"#)
.exec()?;
Ok(())
}
fn register_set(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, t: Table| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_set(&mut cfg, t)
})?;
owlry.set("set", f)?;
Ok(())
}
fn register_providers(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, t: Table| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_providers(&mut cfg, t)
})?;
owlry.set("providers", f)?;
Ok(())
}
fn register_tabs(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, t: Table| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_tabs(&mut cfg, t)
})?;
owlry.set("tabs", f)?;
Ok(())
}
fn register_provider(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, t: Table| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_provider(&mut cfg, t)
})?;
owlry.set("provider", f)?;
Ok(())
}
/// Merge keys from `owlry.set { ... }` into the accumulating config.
/// Per §4.1: multiple `set` calls merge — only keys present in this call
/// override existing values; unmentioned keys keep what they had.
fn apply_set(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
if let Some(v) = t.get::<Option<String>>("theme")? {
cfg.theme = Some(v);
}
if let Some(v) = t.get::<Option<i32>>("width")? {
cfg.width = Some(v);
}
if let Some(v) = t.get::<Option<i32>>("height")? {
cfg.height = Some(v);
}
if let Some(v) = t.get::<Option<u32>>("font_size")? {
cfg.font_size = Some(v);
}
if let Some(v) = t.get::<Option<u32>>("border_radius")? {
cfg.border_radius = Some(v);
}
if let Some(v) = t.get::<Option<String>>("terminal")? {
cfg.terminal = Some(v);
}
if let Some(v) = t.get::<Option<bool>>("use_uwsm")? {
cfg.use_uwsm = Some(v);
}
if let Some(v) = t.get::<Option<bool>>("show_icons")? {
cfg.show_icons = Some(v);
}
if let Some(v) = t.get::<Option<usize>>("max_results")? {
cfg.max_results = Some(v);
}
if let Some(v) = t.get::<Option<bool>>("frecency")? {
cfg.frecency = Some(v);
}
if let Some(v) = t.get::<Option<f64>>("frecency_weight")? {
cfg.frecency_weight = Some(v);
}
if let Some(v) = t.get::<Option<String>>("search_engine")? {
cfg.search_engine = Some(v);
}
// Record unknown keys (string-keyed, non-known) for `config validate`.
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (k, _v) = pair?;
if let mlua::Value::String(s) = k {
let key = s.to_str()?.to_string();
if !KNOWN_SET_KEYS.contains(&key.as_str()) && !cfg.unknown_settings.contains(&key) {
cfg.unknown_settings.push(key);
}
}
}
Ok(())
}
/// `owlry.providers { ... }` — replaces the enabled list on each call (§4.2).
fn apply_providers(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
let list: Vec<String> = t
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?;
cfg.providers = Some(list);
Ok(())
}
/// `owlry.tabs { ... }` — replaces the tab list on each call (§4.3).
fn apply_tabs(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
let list: Vec<String> = t
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?;
cfg.tabs = Some(list);
Ok(())
}
/// `owlry.provider { id = ..., items = function() ... end, ... }` —
/// register a user-defined provider per §4.4. Accumulates across calls;
/// duplicate `id` replaces the prior registration with a warning.
fn apply_provider(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
// Required: id and items. mlua converts missing keys → error here.
let id: String = t.get("id").map_err(|_| {
mlua::Error::RuntimeError(
"owlry.provider: `id` is required (string, lowercase a-z 0-9 - _)".into(),
)
})?;
if !is_valid_provider_id(&id) {
return Err(mlua::Error::RuntimeError(format!(
"owlry.provider: id '{}' invalid — must be lowercase alphanumeric with `-`/`_`",
id
)));
}
let items_fn: Function = t.get("items").map_err(|_| {
mlua::Error::RuntimeError(format!(
"owlry.provider({}): `items` is required and must be a function",
id
))
})?;
// Optional fields.
let name: Option<String> = t.get("name")?;
let prefix: Option<String> = t.get("prefix")?;
let tab_label: Option<String> = t.get("tab_label")?;
let icon: Option<String> = t.get("icon")?;
let search_noun: Option<String> = t.get("search_noun")?;
let priority: u32 = t.get::<Option<u32>>("priority")?.unwrap_or(0);
let dynamic: bool = t.get::<Option<bool>>("dynamic")?.unwrap_or(false);
if dynamic {
return Err(mlua::Error::RuntimeError(format!(
"owlry.provider({}): `dynamic = true` is not yet supported (lands in 2.2). \
Drop the field or set it to false; 2.1 caches items once at startup.",
id
)));
}
let spec = LuaProviderSpec {
id: id.clone(),
name,
prefix,
tab_label,
icon,
search_noun,
priority,
dynamic,
items_fn,
};
// Duplicate id → second wins, warning emitted (spec §6.4). Also recorded
// on the snapshot for `owlry config validate` to surface.
if let Some(pos) = cfg.user_providers.iter().position(|s| s.id == id) {
warn!(
"owlry.provider: duplicate id '{}' — second definition replaces the first",
id
);
cfg.user_providers[pos] = spec;
if !cfg.duplicate_user_provider_ids.contains(&id) {
cfg.duplicate_user_provider_ids.push(id);
}
} else {
cfg.user_providers.push(spec);
}
Ok(())
}
fn register_theme(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, v: Value| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_theme(&mut cfg, v)
})?;
owlry.set("theme", f)?;
Ok(())
}
fn register_profiles(
lua: &Lua,
owlry: &Table,
state: Arc<Mutex<LuaConfig>>,
) -> Result<(), LuaConfigError> {
let f = lua.create_function(move |_, t: Table| {
let mut cfg = state.lock().expect("lua config state mutex poisoned");
apply_profiles(&mut cfg, t)
})?;
owlry.set("profiles", f)?;
Ok(())
}
/// `owlry.theme(...)` — accepts either a string (theme name) or a table
/// (inline colour overrides). Both forms can be combined across calls per
/// §4.5: name set by string-form, table-form layered on top.
fn apply_theme(cfg: &mut LuaConfig, value: Value) -> mlua::Result<()> {
match value {
Value::String(s) => {
cfg.theme_name = Some(s.to_str()?.to_string());
Ok(())
}
Value::Table(t) => apply_theme_table(cfg, t),
other => Err(mlua::Error::RuntimeError(format!(
"owlry.theme: expected a string (theme name) or table (colour overrides), got {}",
other.type_name()
))),
}
}
/// Read recognised colour keys from a theme table and overlay them on the
/// accumulated [`super::config::LuaConfig::theme_colors`]. Unknown keys are
/// recorded (forward-compat) — no error, mirroring `owlry.set` policy.
fn apply_theme_table(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
macro_rules! read_colour {
($field:ident) => {
if let Some(v) = t.get::<Option<String>>(stringify!($field))? {
cfg.theme_colors.$field = Some(v);
}
};
}
read_colour!(background);
read_colour!(background_secondary);
read_colour!(border);
read_colour!(text);
read_colour!(text_secondary);
read_colour!(accent);
read_colour!(accent_bright);
read_colour!(badge_app);
read_colour!(badge_bookmark);
read_colour!(badge_calc);
read_colour!(badge_clip);
read_colour!(badge_cmd);
read_colour!(badge_dmenu);
read_colour!(badge_emoji);
read_colour!(badge_file);
read_colour!(badge_script);
read_colour!(badge_ssh);
read_colour!(badge_power);
read_colour!(badge_uuctl);
read_colour!(badge_web);
read_colour!(badge_media);
read_colour!(badge_weather);
read_colour!(badge_pomo);
// Pre-v2 alias: badge_sys → badge_power (matches the serde alias on
// ThemeColors in config/mod.rs). Only applies when the canonical key
// wasn't already set in this call.
if cfg.theme_colors.badge_power.is_none()
&& let Some(v) = t.get::<Option<String>>("badge_sys")?
{
cfg.theme_colors.badge_power = Some(v);
}
// Track unknown theme keys for `owlry config validate`.
for pair in t.pairs::<Value, Value>() {
let (k, _) = pair?;
if let Value::String(s) = k {
let key = s.to_str()?.to_string();
if !KNOWN_THEME_KEYS.contains(&key.as_str())
&& !cfg.unknown_theme_keys.contains(&key)
{
cfg.unknown_theme_keys.push(key);
}
}
}
Ok(())
}
/// `owlry.profiles { dev = { "app", "cmd" }, ... }` — captures named
/// provider-id sets used by `--profile <name>`. Replaces the entire map
/// on each call (consistent with providers/tabs replace-on-call semantics).
fn apply_profiles(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
let mut profiles: HashMap<String, Vec<String>> = HashMap::new();
for pair in t.pairs::<String, Table>() {
let (name, modes_tbl) = pair.map_err(|e| {
mlua::Error::RuntimeError(format!(
"owlry.profiles: every value must be a list of provider ids — {}",
e
))
})?;
let modes: Vec<String> = modes_tbl
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()
.map_err(|e| {
mlua::Error::RuntimeError(format!(
"owlry.profiles.{}: list entries must be strings — {}",
name, e
))
})?;
profiles.insert(name, modes);
}
cfg.profiles = Some(profiles);
Ok(())
}
+635
View File
@@ -0,0 +1,635 @@
//! Data layer for the Lua config: the [`LuaConfig`] struct that accumulates
//! state from `owlry.set` / `owlry.providers` / `owlry.tabs` / `owlry.theme`
//! / `owlry.profiles` / `owlry.provider` calls, plus the merge that overlays
//! it onto the existing TOML-derived [`crate::config::Config`].
//!
//! Mapping mirrors `docs/lua-api.md` §4.1–§4.5 and §6.5. Every settable
//! key is an `Option<T>`: `None` means "the user didn't touch it, keep the
//! base value."
use std::collections::HashMap;
use crate::config::{Config, ProfileConfig, ThemeColors};
/// Accumulated state from `owlry.lua`. Built up by the closures registered in
/// [`super::api::install`] and drained via [`super::runtime::LuaContext::snapshot`].
#[derive(Debug, Default, Clone)]
pub struct LuaConfig {
// ─── owlry.set { ... } ─────────────────────────────────────────────────
pub theme: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub font_size: Option<u32>,
pub border_radius: Option<u32>,
pub terminal: Option<String>,
pub use_uwsm: Option<bool>,
pub show_icons: Option<bool>,
pub max_results: Option<usize>,
pub frecency: Option<bool>,
pub frecency_weight: Option<f64>,
pub search_engine: Option<String>,
/// Keys passed to `owlry.set` that we don't recognise. Surfaced by
/// `owlry config validate` (Phase 3.9). Not an error — forward-compat.
pub unknown_settings: Vec<String>,
/// `None` = `owlry.providers` was never called → keep base defaults.
/// `Some(list)` = explicit set; ids absent from the list are disabled.
pub providers: Option<Vec<String>>,
/// `None` = `owlry.tabs` was never called → keep base defaults.
/// `Some(list)` = explicit set (may be empty to hide all tabs).
pub tabs: Option<Vec<String>>,
/// User providers registered via `owlry.provider { ... }`. Order matches
/// registration. Duplicates by `id` are resolved at registration time:
/// later wins, earlier is dropped (warning logged).
pub user_providers: Vec<LuaProviderSpec>,
/// IDs that appeared more than once in `owlry.provider { ... }` calls.
/// Stored separately so `owlry config validate` (Phase 3.9) can surface
/// them — the dedup in [`super::api::apply_provider`] would otherwise
/// erase the evidence by the time the snapshot is read.
pub duplicate_user_provider_ids: Vec<String>,
// ─── owlry.theme(...) ─────────────────────────────────────────────────
/// Theme name from the string form `owlry.theme("nord")`. Last write wins
/// across multiple calls (string forms merge with table forms — see
/// §4.5: "call owlry.theme(name) first, then owlry.theme { ... } to
/// layer overrides on top").
pub theme_name: Option<String>,
/// Inline colour overrides from the table form `owlry.theme { ... }`.
/// Per-key last-write-wins. Empty by default (no overrides).
pub theme_colors: ThemeColors,
/// Theme keys we don't recognise (forward-compat for 2.2+ palette
/// additions). Surfaced by `owlry config validate` (Phase 3.9).
pub unknown_theme_keys: Vec<String>,
// ─── owlry.profiles { ... } ───────────────────────────────────────────
/// Named profiles from `owlry.profiles { dev = { ... }, ... }`. Each
/// profile maps to a list of provider ids used when `--profile <name>`
/// is passed. `None` = profiles were never set in Lua → keep TOML or
/// defaults. `Some(map)` replaces the base profiles (consistent with
/// `providers`/`tabs` replace-on-call semantics).
pub profiles: Option<HashMap<String, Vec<String>>>,
}
/// Captured spec from a single `owlry.provider { ... }` call.
///
/// Holds the `mlua::Function` for the `items` callback alive — that keeps
/// the parent Lua state alive too (Function holds an internal Arc).
#[derive(Clone)]
pub struct LuaProviderSpec {
pub id: String,
pub name: Option<String>,
pub prefix: Option<String>,
pub tab_label: Option<String>,
pub icon: Option<String>,
pub search_noun: Option<String>,
pub priority: u32,
/// 2.1 supports only `false` (static, cached after first refresh).
/// `true` errors at registration time (per docs/lua-api.md §4.4).
pub dynamic: bool,
pub items_fn: mlua::Function,
}
impl std::fmt::Debug for LuaProviderSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LuaProviderSpec")
.field("id", &self.id)
.field("name", &self.name)
.field("prefix", &self.prefix)
.field("tab_label", &self.tab_label)
.field("icon", &self.icon)
.field("search_noun", &self.search_noun)
.field("priority", &self.priority)
.field("dynamic", &self.dynamic)
.field("items_fn", &"<lua function>")
.finish()
}
}
/// Validate a user-supplied provider id. Spec §4.4: "Must be lowercase,
/// alphanumeric + `-`/`_`."
pub(crate) fn is_valid_provider_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Known keys accepted by `owlry.set`. Anything else lands in
/// [`LuaConfig::unknown_settings`].
pub(crate) const KNOWN_SET_KEYS: &[&str] = &[
"theme",
"width",
"height",
"font_size",
"border_radius",
"terminal",
"use_uwsm",
"show_icons",
"max_results",
"frecency",
"frecency_weight",
"search_engine",
];
/// Known keys accepted by `owlry.theme { ... }`. Mirrors the fields of
/// [`ThemeColors`]; the pre-v2 `badge_sys` alias is included so existing
/// configs translate cleanly (it maps to `badge_power` per the v2 rename).
pub(crate) const KNOWN_THEME_KEYS: &[&str] = &[
"background",
"background_secondary",
"border",
"text",
"text_secondary",
"accent",
"accent_bright",
"badge_app",
"badge_bookmark",
"badge_calc",
"badge_clip",
"badge_cmd",
"badge_dmenu",
"badge_emoji",
"badge_file",
"badge_script",
"badge_ssh",
"badge_power",
"badge_sys", // pre-v2 alias, normalised to badge_power at merge time
"badge_uuctl",
"badge_web",
"badge_media",
"badge_weather",
"badge_pomo",
];
impl LuaConfig {
/// Apply the Lua-side overrides on top of an existing [`Config`].
///
/// Fields the user didn't set (`None`) are left untouched. `providers`
/// and `tabs` lists, when present, fully replace the corresponding base
/// state (per §4.2 / §4.3 — "calling replaces, it's not additive").
pub fn merge_into(&self, cfg: &mut Config) {
// ── owlry.set ──────────────────────────────────────────────────
if let Some(v) = &self.theme {
cfg.appearance.theme = Some(v.clone());
}
if let Some(v) = self.width {
cfg.appearance.width = v;
}
if let Some(v) = self.height {
cfg.appearance.height = v;
}
if let Some(v) = self.font_size {
cfg.appearance.font_size = v;
}
if let Some(v) = self.border_radius {
cfg.appearance.border_radius = v;
}
if let Some(v) = &self.terminal {
cfg.general.terminal_command = Some(v.clone());
}
if let Some(v) = self.use_uwsm {
cfg.general.use_uwsm = v;
}
if let Some(v) = self.show_icons {
cfg.general.show_icons = v;
}
if let Some(v) = self.max_results {
cfg.general.max_results = v;
}
if let Some(v) = self.frecency {
cfg.providers.frecency = v;
}
if let Some(v) = self.frecency_weight {
cfg.providers.frecency_weight = v;
}
if let Some(v) = &self.search_engine {
cfg.providers.search_engine = v.clone();
}
// ── owlry.providers ────────────────────────────────────────────
if let Some(list) = &self.providers {
apply_providers_list(&mut cfg.providers, list);
}
// ── owlry.tabs ─────────────────────────────────────────────────
if let Some(list) = &self.tabs {
cfg.general.tabs = list.clone();
}
// ── owlry.theme(...) ───────────────────────────────────────────
if let Some(name) = &self.theme_name {
cfg.appearance.theme = Some(name.clone());
}
merge_theme_colors(&self.theme_colors, &mut cfg.appearance.colors);
// ── owlry.profiles { ... } ─────────────────────────────────────
// Replace base profiles entirely (consistent with providers/tabs
// replace-on-call). Profile values become `ProfileConfig { modes }`.
if let Some(profiles) = &self.profiles {
cfg.profiles.clear();
for (name, modes) in profiles {
cfg.profiles.insert(
name.clone(),
ProfileConfig {
modes: modes.clone(),
},
);
}
}
}
}
/// Overlay any `Some` colour fields from the Lua side onto the base
/// [`ThemeColors`]. `None` fields are skipped so the base theme survives.
fn merge_theme_colors(src: &ThemeColors, dst: &mut ThemeColors) {
macro_rules! merge {
($field:ident) => {
if let Some(ref v) = src.$field {
dst.$field = Some(v.clone());
}
};
}
merge!(background);
merge!(background_secondary);
merge!(border);
merge!(text);
merge!(text_secondary);
merge!(accent);
merge!(accent_bright);
merge!(badge_app);
merge!(badge_bookmark);
merge!(badge_calc);
merge!(badge_clip);
merge!(badge_cmd);
merge!(badge_dmenu);
merge!(badge_emoji);
merge!(badge_file);
merge!(badge_script);
merge!(badge_ssh);
merge!(badge_power);
merge!(badge_uuctl);
merge!(badge_web);
merge!(badge_media);
merge!(badge_weather);
merge!(badge_pomo);
}
/// Translate a list of provider IDs (as the user wrote them in
/// `owlry.providers { ... }`) into the per-provider boolean flags on
/// [`crate::config::ProvidersConfig`]. Ids not present in the list are
/// disabled. Honours v1→v2 aliases (`sys`/`system` → `power`,
/// `uuctl` → `systemd`).
fn apply_providers_list(p: &mut crate::config::ProvidersConfig, list: &[String]) {
// Start from "everything off"; the list determines who flips on.
p.applications = false;
p.commands = false;
p.calculator = false;
p.converter = false;
p.power = false;
p.systemd = false;
p.clipboard = false;
p.emoji = false;
p.filesearch = false;
p.ssh = false;
p.websearch = false;
for id in list {
match id.as_str() {
"app" | "application" | "applications" => p.applications = true,
"cmd" | "command" | "commands" => p.commands = true,
"calc" | "calculator" => p.calculator = true,
"conv" | "converter" => p.converter = true,
"power" | "sys" | "system" => p.power = true,
"systemd" | "uuctl" => p.systemd = true,
"ssh" => p.ssh = true,
"clipboard" | "clip" => p.clipboard = true,
"emoji" => p.emoji = true,
"filesearch" | "file" => p.filesearch = true,
"websearch" | "web" | "search" => p.websearch = true,
// dmenu is a CLI-mode-only provider with no ProvidersConfig bool.
// Unknown ids are tolerated here; validation (Phase 3.9) catches them.
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_lua_config_is_empty() {
let lc = LuaConfig::default();
assert!(lc.theme.is_none());
assert!(lc.width.is_none());
assert!(lc.providers.is_none());
assert!(lc.tabs.is_none());
assert!(lc.unknown_settings.is_empty());
}
#[test]
fn empty_lua_config_leaves_base_config_untouched() {
let mut cfg = Config::default();
let base_width = cfg.appearance.width;
let base_max = cfg.general.max_results;
let base_app = cfg.providers.applications;
LuaConfig::default().merge_into(&mut cfg);
assert_eq!(cfg.appearance.width, base_width);
assert_eq!(cfg.general.max_results, base_max);
assert_eq!(cfg.providers.applications, base_app);
}
#[test]
fn merge_overrides_appearance_settings() {
let mut cfg = Config::default();
let lc = LuaConfig {
theme: Some("catppuccin-mocha".into()),
width: Some(900),
height: Some(700),
font_size: Some(16),
border_radius: Some(8),
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(cfg.appearance.theme.as_deref(), Some("catppuccin-mocha"));
assert_eq!(cfg.appearance.width, 900);
assert_eq!(cfg.appearance.height, 700);
assert_eq!(cfg.appearance.font_size, 16);
assert_eq!(cfg.appearance.border_radius, 8);
}
#[test]
fn merge_overrides_general_settings() {
let mut cfg = Config::default();
let lc = LuaConfig {
terminal: Some("kitty".into()),
use_uwsm: Some(true),
show_icons: Some(false),
max_results: Some(50),
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(cfg.general.terminal_command.as_deref(), Some("kitty"));
assert!(cfg.general.use_uwsm);
assert!(!cfg.general.show_icons);
assert_eq!(cfg.general.max_results, 50);
}
#[test]
fn merge_overrides_frecency_and_search() {
let mut cfg = Config::default();
let lc = LuaConfig {
frecency: Some(false),
frecency_weight: Some(0.75),
search_engine: Some("google".into()),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(!cfg.providers.frecency);
assert!((cfg.providers.frecency_weight - 0.75).abs() < f64::EPSILON);
assert_eq!(cfg.providers.search_engine, "google");
}
#[test]
fn providers_list_enables_only_listed_ids() {
let mut cfg = Config::default();
// Defaults: everything is true. Lua list specifies only a subset.
let lc = LuaConfig {
providers: Some(vec!["app".into(), "cmd".into(), "calc".into()]),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(cfg.providers.applications);
assert!(cfg.providers.commands);
assert!(cfg.providers.calculator);
// Not listed → disabled.
assert!(!cfg.providers.converter);
assert!(!cfg.providers.power);
assert!(!cfg.providers.systemd);
assert!(!cfg.providers.ssh);
assert!(!cfg.providers.emoji);
assert!(!cfg.providers.clipboard);
assert!(!cfg.providers.filesearch);
assert!(!cfg.providers.websearch);
}
#[test]
fn providers_list_honours_v1_aliases() {
let mut cfg = Config::default();
let lc = LuaConfig {
// sys/system → power; uuctl → systemd
providers: Some(vec!["sys".into(), "uuctl".into()]),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(cfg.providers.power);
assert!(cfg.providers.systemd);
// None of the actually-listed canonical ids show up:
assert!(!cfg.providers.applications);
assert!(!cfg.providers.commands);
}
#[test]
fn providers_list_ignores_unknown_ids() {
let mut cfg = Config::default();
let lc = LuaConfig {
providers: Some(vec!["app".into(), "fictional_provider".into()]),
..Default::default()
};
// Should not panic; just silently skips the unknown.
lc.merge_into(&mut cfg);
assert!(cfg.providers.applications);
}
#[test]
fn empty_providers_list_disables_everything() {
let mut cfg = Config::default();
let lc = LuaConfig {
providers: Some(vec![]),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(!cfg.providers.applications);
assert!(!cfg.providers.commands);
assert!(!cfg.providers.power);
assert!(!cfg.providers.systemd);
}
#[test]
fn tabs_list_replaces_tab_order() {
let mut cfg = Config::default();
let lc = LuaConfig {
tabs: Some(vec!["app".into(), "systemd".into(), "ssh".into()]),
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(cfg.general.tabs, vec!["app", "systemd", "ssh"]);
}
#[test]
fn empty_tabs_list_hides_all_tabs() {
let mut cfg = Config::default();
let lc = LuaConfig {
tabs: Some(vec![]),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(cfg.general.tabs.is_empty());
}
#[test]
fn valid_provider_id_accepts_lowercase_alnum_dash_underscore() {
assert!(is_valid_provider_id("hs"));
assert!(is_valid_provider_id("my-provider"));
assert!(is_valid_provider_id("my_provider"));
assert!(is_valid_provider_id("p1"));
assert!(is_valid_provider_id("a-b_c1"));
}
#[test]
fn valid_provider_id_rejects_invalid_shapes() {
assert!(!is_valid_provider_id(""));
assert!(!is_valid_provider_id("Capital"));
assert!(!is_valid_provider_id("has space"));
assert!(!is_valid_provider_id("dot.in.id"));
assert!(!is_valid_provider_id("colon:bad"));
assert!(!is_valid_provider_id("emoji_💀"));
}
// ── owlry.theme(...) merge semantics ────────────────────────────────────
#[test]
fn theme_name_overrides_base_appearance_theme() {
let mut cfg = Config::default();
let lc = LuaConfig {
theme_name: Some("catppuccin-mocha".into()),
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(
cfg.appearance.theme.as_deref(),
Some("catppuccin-mocha")
);
}
#[test]
fn theme_colours_overlay_only_set_fields() {
let mut cfg = Config::default();
// Base theme has colours already set:
cfg.appearance.colors.background = Some("#000000".into());
cfg.appearance.colors.text = Some("#ffffff".into());
// Lua overlays only the accent — background and text must survive.
let mut lua_colors = ThemeColors::default();
lua_colors.accent = Some("#ff00ff".into());
let lc = LuaConfig {
theme_colors: lua_colors,
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(cfg.appearance.colors.background.as_deref(), Some("#000000"));
assert_eq!(cfg.appearance.colors.text.as_deref(), Some("#ffffff"));
assert_eq!(cfg.appearance.colors.accent.as_deref(), Some("#ff00ff"));
}
#[test]
fn theme_name_and_colours_compose() {
let mut cfg = Config::default();
let mut lua_colors = ThemeColors::default();
lua_colors.background = Some("#1e1e2e".into());
let lc = LuaConfig {
theme_name: Some("nord".into()),
theme_colors: lua_colors,
..Default::default()
};
lc.merge_into(&mut cfg);
assert_eq!(cfg.appearance.theme.as_deref(), Some("nord"));
assert_eq!(cfg.appearance.colors.background.as_deref(), Some("#1e1e2e"));
}
// ── owlry.profiles { ... } merge semantics ──────────────────────────────
#[test]
fn profiles_map_replaces_base_profiles() {
let mut cfg = Config::default();
// Base TOML had a "legacy" profile that must be cleared.
cfg.profiles.insert(
"legacy".into(),
ProfileConfig {
modes: vec!["app".into()],
},
);
let mut profiles: HashMap<String, Vec<String>> = HashMap::new();
profiles.insert("dev".into(), vec!["app".into(), "cmd".into(), "ssh".into()]);
profiles.insert("media".into(), vec!["emoji".into(), "clipboard".into()]);
let lc = LuaConfig {
profiles: Some(profiles),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(
!cfg.profiles.contains_key("legacy"),
"Lua profiles must replace base entries entirely"
);
assert_eq!(cfg.profiles.len(), 2);
assert_eq!(
cfg.profiles.get("dev").map(|p| p.modes.as_slice()),
Some(&["app".to_string(), "cmd".to_string(), "ssh".to_string()][..])
);
assert_eq!(
cfg.profiles.get("media").map(|p| p.modes.as_slice()),
Some(&["emoji".to_string(), "clipboard".to_string()][..])
);
}
#[test]
fn empty_profiles_clears_base_profiles() {
let mut cfg = Config::default();
cfg.profiles.insert(
"legacy".into(),
ProfileConfig {
modes: vec!["app".into()],
},
);
let lc = LuaConfig {
profiles: Some(HashMap::new()),
..Default::default()
};
lc.merge_into(&mut cfg);
assert!(cfg.profiles.is_empty());
}
#[test]
fn omitted_profiles_keeps_base_profiles() {
let mut cfg = Config::default();
cfg.profiles.insert(
"legacy".into(),
ProfileConfig {
modes: vec!["app".into()],
},
);
let lc = LuaConfig::default(); // profiles: None
lc.merge_into(&mut cfg);
assert!(cfg.profiles.contains_key("legacy"));
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Error type for Lua configuration loading and evaluation.
//!
//! Distinct from [`crate::config::ConfigError`] so that the daemon can report
//! "your Lua config failed" separately from "your TOML config failed" and
//! pinpoint the offending file path.
use std::path::PathBuf;
use thiserror::Error;
// Display strings here intentionally OMIT `{source}` — the wrapping is done
// elsewhere by walking `.source()`. Embedding it twice (once in Display,
// once via the chain) produced duplicated text in hot-reload error
// notifications (Phase 3.7).
#[derive(Debug, Error)]
pub enum LuaConfigError {
#[error("failed to read Lua config at {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Lua evaluation error in {path}")]
Eval {
path: PathBuf,
#[source]
source: mlua::Error,
},
#[error("invalid value for `{key}` in {path}: {message}")]
InvalidValue {
path: PathBuf,
key: String,
message: String,
},
#[error("Lua runtime error: {0}")]
Runtime(#[from] mlua::Error),
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
//! Lua configuration layer (Phase 3).
//!
//! The user configuration surface lives at `~/.config/owlry/owlry.lua` (D23).
//! In 2.1 this is an opt-in preview behind the `lua` cargo feature; the
//! existing TOML loader in [`crate::config`] remains the source of truth
//! when no `owlry.lua` is present. See `docs/lua-api.md` for the full
//! design (D1D24) and roadmap to 3.0.
//!
//! Phase 3.1 only scaffolds the module — every submodule is a stub with no
//! call sites yet. Wiring happens in 3.2+.
pub mod api;
pub mod config;
pub mod error;
pub mod migrate;
pub mod provider;
pub mod runtime;
pub mod util;
pub mod validate;
pub mod watcher;
pub use config::{LuaConfig, LuaProviderSpec};
pub use error::LuaConfigError;
pub use migrate::{MigrateError, MigrateOutcome, MigrateRequest};
pub use provider::LuaProvider;
pub use runtime::LuaContext;
pub use validate::ValidationReport;
pub use watcher::ConfigWatcher;
+477
View File
@@ -0,0 +1,477 @@
//! [`LuaProvider`] — bridges a [`LuaProviderSpec`] (a captured `owlry.provider {}`
//! registration) onto the host-side [`Provider`] trait.
//!
//! Phase 3.3 ships **static** providers only (per `docs/lua-api.md` §4.4,
//! "2.1 ships only `dynamic = false`"): the Lua `items` function is called
//! once via [`Provider::refresh`] with an empty query, results are cached,
//! and subsequent `Provider::items` calls return that cache. `dynamic = true`
//! lands in Phase 3 follow-up (2.2).
//!
//! Errors raised by the user's `items` function are logged and result in an
//! empty cache for that refresh — the daemon must never crash because of a
//! broken user provider.
use std::sync::Arc;
use log::{debug, error, warn};
use mlua::{Lua, Table};
use crate::providers::{ItemSource, LaunchItem, Provider, ProviderType};
use super::config::LuaProviderSpec;
const DEFAULT_ICON: &str = "application-x-addon";
pub struct LuaProvider {
spec: LuaProviderSpec,
/// Keeps the parent Lua state alive — `mlua::Function` references don't
/// bump the Lua refcount on their own, so dropping the state would
/// invalidate `spec.items_fn`. The handle is never accessed directly.
_lua: Arc<Lua>,
cached: Vec<LaunchItem>,
}
impl LuaProvider {
pub fn new(spec: LuaProviderSpec, lua: Arc<Lua>) -> Self {
Self {
spec,
_lua: lua,
cached: Vec::new(),
}
}
/// The user-supplied id (also the [`ProviderType::Plugin`] payload).
pub fn id(&self) -> &str {
&self.spec.id
}
fn type_id(&self) -> String {
self.spec.id.clone()
}
}
impl Provider for LuaProvider {
fn name(&self) -> &str {
self.spec
.name
.as_deref()
.unwrap_or(self.spec.id.as_str())
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.type_id())
}
fn priority(&self) -> u32 {
self.spec.priority
}
fn prefix(&self) -> Option<&str> {
self.spec.prefix.as_deref()
}
fn icon(&self) -> &str {
self.spec.icon.as_deref().unwrap_or(DEFAULT_ICON)
}
fn tab_label(&self) -> Option<&str> {
self.spec.tab_label.as_deref()
}
fn search_noun(&self) -> Option<&str> {
self.spec.search_noun.as_deref()
}
fn refresh(&mut self) {
// Spec §4.4: dynamic=false is "called once at startup and cached".
// We pass an empty query — the user's items function is expected to
// return the full universe when no query is supplied.
match self.spec.items_fn.call::<Vec<Table>>("") {
Ok(rows) => {
let mut items = Vec::with_capacity(rows.len());
for (idx, row) in rows.into_iter().enumerate() {
match parse_item(&row, &self.spec.id, idx) {
Ok(it) => items.push(it),
Err(e) => warn!(
"user provider '{}': dropping item #{} — {}",
self.spec.id, idx, e
),
}
}
debug!(
"user provider '{}': refreshed {} item(s)",
self.spec.id,
items.len()
);
self.cached = items;
}
Err(e) => {
error!(
"user provider '{}': items() raised — {} — returning 0 results",
self.spec.id, e
);
self.cached.clear();
}
}
}
fn items(&self) -> &[LaunchItem] {
&self.cached
}
}
/// Parse a single Lua item table into a [`LaunchItem`]. `idx` is the
/// 0-based position in the returned list — used to synthesize a stable item id.
fn parse_item(t: &Table, provider_id: &str, idx: usize) -> mlua::Result<LaunchItem> {
let name: String = t.get("name").map_err(|_| {
mlua::Error::RuntimeError("item missing required field `name` (string)".into())
})?;
let command: String = t.get("command").map_err(|_| {
mlua::Error::RuntimeError(format!(
"item '{}' missing required field `command` (string)",
name
))
})?;
let description: Option<String> = t.get("description")?;
let icon: Option<String> = t.get("icon")?;
let terminal: bool = t.get::<Option<bool>>("terminal")?.unwrap_or(false);
let tags: Vec<String> = t
.get::<Option<Table>>("tags")?
.map(|tt| tt.sequence_values::<String>().collect::<mlua::Result<Vec<_>>>())
.transpose()?
.unwrap_or_default();
Ok(LaunchItem {
id: format!("{}:{}:{}", provider_id, idx, name),
name,
description,
icon,
provider: ProviderType::Plugin(provider_id.to_string()),
command,
terminal,
tags,
source: ItemSource::ScriptPlugin,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lua::runtime::LuaContext;
/// Build a LuaProvider from a single `owlry.provider {}` invocation.
/// The LuaContext is dropped at the end of `build` — the `Arc<Lua>` held
/// by the returned LuaProvider keeps the underlying state alive.
fn build(src: &str) -> LuaProvider {
let ctx = LuaContext::new().expect("ctx");
ctx.eval_str(src).expect("script");
let lua = ctx.lua_handle();
let cfg = ctx.snapshot();
let spec = cfg
.user_providers
.into_iter()
.next()
.expect("expected one user provider");
LuaProvider::new(spec, lua)
}
#[test]
fn minimal_provider_captures_id_and_defaults() {
let p = build(
r#"owlry.provider { id = "hs", items = function() return {} end }"#,
);
assert_eq!(p.id(), "hs");
assert_eq!(p.name(), "hs"); // name defaults to id
assert_eq!(p.icon(), "application-x-addon");
assert!(p.prefix().is_none());
assert!(p.tab_label().is_none());
assert!(p.search_noun().is_none());
assert_eq!(p.priority(), 0);
assert_eq!(p.provider_type(), ProviderType::Plugin("hs".into()));
}
#[test]
fn full_provider_captures_all_optional_fields() {
let p = build(
r#"
owlry.provider {
id = "hs",
name = "Hyprland Shutdown",
prefix = ":hs",
tab_label = "Shutdown",
icon = "system-shutdown",
search_noun = "shutdown actions",
priority = 42,
items = function() return {} end,
}
"#,
);
assert_eq!(p.id(), "hs");
assert_eq!(p.name(), "Hyprland Shutdown");
assert_eq!(p.prefix(), Some(":hs"));
assert_eq!(p.tab_label(), Some("Shutdown"));
assert_eq!(p.icon(), "system-shutdown");
assert_eq!(p.search_noun(), Some("shutdown actions"));
assert_eq!(p.priority(), 42);
}
#[test]
fn refresh_calls_items_function_and_caches() {
let mut p = build(
r#"
owlry.provider {
id = "hs",
items = function()
return {
{ name = "Lock", command = "hyprlock" },
{ name = "Shutdown", command = "systemctl poweroff" },
}
end,
}
"#,
);
assert!(p.items().is_empty(), "must start empty before refresh");
p.refresh();
let items = p.items();
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "Lock");
assert_eq!(items[0].command, "hyprlock");
assert_eq!(items[0].provider, ProviderType::Plugin("hs".into()));
assert_eq!(items[0].source, ItemSource::ScriptPlugin);
assert!(!items[0].terminal);
assert!(items[0].tags.is_empty());
assert!(items[0].description.is_none());
assert_eq!(items[1].name, "Shutdown");
assert_eq!(items[1].command, "systemctl poweroff");
}
#[test]
fn refresh_parses_all_optional_item_fields() {
let mut p = build(
r#"
owlry.provider {
id = "hs",
items = function()
return {
{
name = "Reboot",
command = "systemctl reboot",
description = "Reboot the machine",
icon = "system-reboot",
terminal = true,
tags = { "power", "destructive" },
},
}
end,
}
"#,
);
p.refresh();
let it = &p.items()[0];
assert_eq!(it.name, "Reboot");
assert_eq!(it.command, "systemctl reboot");
assert_eq!(it.description.as_deref(), Some("Reboot the machine"));
assert_eq!(it.icon.as_deref(), Some("system-reboot"));
assert!(it.terminal);
assert_eq!(it.tags, vec!["power", "destructive"]);
}
#[test]
fn refresh_drops_items_missing_required_fields() {
let mut p = build(
r#"
owlry.provider {
id = "hs",
items = function()
return {
{ name = "Lock", command = "hyprlock" },
{ name = "No command here" }, -- missing command, drop
{ command = "no name here" }, -- missing name, drop
{ name = "Reboot", command = "systemctl reboot" },
}
end,
}
"#,
);
p.refresh();
let items = p.items();
assert_eq!(items.len(), 2, "only well-formed items kept");
assert_eq!(items[0].name, "Lock");
assert_eq!(items[1].name, "Reboot");
}
#[test]
fn refresh_handles_lua_runtime_error_gracefully() {
let mut p = build(
r#"
owlry.provider {
id = "broken",
items = function() error("kaboom") end,
}
"#,
);
// Should not panic; cache remains empty.
p.refresh();
assert!(p.items().is_empty());
}
#[test]
fn refresh_handles_non_table_return_gracefully() {
let mut p = build(
r#"
owlry.provider {
id = "weird",
items = function() return "not a table" end,
}
"#,
);
p.refresh();
// Cache empty — the mlua::Vec<Table> conversion fails, treated as error.
assert!(p.items().is_empty());
}
#[test]
fn lua_provider_is_send_and_sync() {
// Compile-time check: needed because ProviderManager stores providers
// as `Box<dyn Provider>` where `Provider: Send + Sync`.
fn assert<T: Send + Sync>() {}
assert::<LuaProvider>();
}
#[test]
fn item_ids_are_unique_per_provider() {
let mut p = build(
r#"
owlry.provider {
id = "hs",
items = function()
return {
{ name = "Same", command = "a" },
{ name = "Same", command = "b" },
}
end,
}
"#,
);
p.refresh();
let items = p.items();
assert_ne!(
items[0].id, items[1].id,
"different positions must produce different ids"
);
}
}
#[cfg(test)]
mod registration_tests {
//! Tests for the `owlry.provider` registration path itself (validation,
//! accumulation, duplicate handling). These live here rather than in
//! `runtime.rs` because they exercise [`super::super::config::LuaProviderSpec`].
use crate::lua::runtime::LuaContext;
fn run(src: &str) -> Result<crate::lua::LuaConfig, crate::lua::LuaConfigError> {
let ctx = LuaContext::new()?;
ctx.eval_str(src)?;
Ok(ctx.snapshot())
}
#[test]
fn accumulates_multiple_distinct_providers() {
let cfg = run(
r#"
owlry.provider { id = "a", items = function() return {} end }
owlry.provider { id = "b", items = function() return {} end }
owlry.provider { id = "c", items = function() return {} end }
"#,
)
.unwrap();
let ids: Vec<&str> = cfg.user_providers.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"]);
}
#[test]
fn duplicate_id_replaces_in_place() {
let cfg = run(
r#"
owlry.provider { id = "a", prefix = ":v1", items = function() return {} end }
owlry.provider { id = "b", items = function() return {} end }
owlry.provider { id = "a", prefix = ":v2", items = function() return {} end }
"#,
)
.unwrap();
assert_eq!(cfg.user_providers.len(), 2);
let a = cfg.user_providers.iter().find(|s| s.id == "a").unwrap();
assert_eq!(a.prefix.as_deref(), Some(":v2"), "second `a` wins");
}
#[test]
fn missing_id_is_an_error() {
let err = run(r#"owlry.provider { items = function() return {} end }"#).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("`id` is required"), "got: {}", msg);
}
#[test]
fn missing_items_is_an_error() {
let err = run(r#"owlry.provider { id = "hs" }"#).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("`items` is required"), "got: {}", msg);
}
#[test]
fn invalid_id_format_is_an_error() {
let err = run(
r#"owlry.provider { id = "BAD ID", items = function() return {} end }"#,
)
.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("invalid"), "got: {}", msg);
}
#[test]
fn dynamic_true_is_rejected_in_2_1() {
let err = run(
r#"
owlry.provider {
id = "x", dynamic = true,
items = function() return {} end,
}
"#,
)
.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("dynamic"), "got: {}", msg);
assert!(msg.contains("2.2"), "should mention 2.2 roadmap; got: {}", msg);
}
#[test]
fn dynamic_false_explicit_is_fine() {
let cfg = run(
r#"
owlry.provider {
id = "x", dynamic = false,
items = function() return {} end,
}
"#,
)
.unwrap();
assert_eq!(cfg.user_providers.len(), 1);
assert!(!cfg.user_providers[0].dynamic);
}
#[test]
fn priority_is_captured() {
let cfg = run(
r#"
owlry.provider {
id = "x", priority = 999,
items = function() return {} end,
}
"#,
)
.unwrap();
assert_eq!(cfg.user_providers[0].priority, 999);
}
}
+562
View File
@@ -0,0 +1,562 @@
//! Lua runtime wrapper.
//!
//! [`LuaContext`] owns a single `mlua::Lua` state with the `owlry.*` API
//! installed and an `Arc<Mutex<LuaConfig>>` shared with the registered
//! closures. Evaluate user files via [`LuaContext::eval_file`] (or
//! [`LuaContext::eval_str`] for tests) and snapshot the accumulated state
//! via [`LuaContext::snapshot`].
//!
//! The Lua state is wrapped in [`Arc`] so [`super::provider::LuaProvider`]
//! instances can keep it alive independently of the context. `mlua::Function`
//! references don't bump the Lua refcount on their own — drop the state and
//! they panic at call time. Holding an `Arc<Lua>` in each provider closes
//! that gap.
use std::path::Path;
use std::sync::{Arc, Mutex};
use mlua::Lua;
use super::api;
use super::config::LuaConfig;
use super::error::LuaConfigError;
pub struct LuaContext {
lua: Arc<Lua>,
state: Arc<Mutex<LuaConfig>>,
}
impl LuaContext {
/// Build a fresh Lua state with the `owlry.*` API surface installed.
pub fn new() -> Result<Self, LuaConfigError> {
let lua = Arc::new(Lua::new());
let state = Arc::new(Mutex::new(LuaConfig::default()));
api::install(&lua, Arc::clone(&state))?;
Ok(Self { lua, state })
}
/// Clone the inner Lua handle. Used by [`super::provider::LuaProvider`]
/// to keep the state alive for the lifetime of the provider.
pub fn lua_handle(&self) -> Arc<Lua> {
Arc::clone(&self.lua)
}
/// Evaluate a Lua file against this context. Reads from disk, runs the
/// script with the file path as its chunk name so errors point at the
/// right place.
pub fn eval_file(&self, path: &Path) -> Result<(), LuaConfigError> {
let content = std::fs::read_to_string(path).map_err(|e| LuaConfigError::Read {
path: path.to_path_buf(),
source: e,
})?;
let name = path.to_string_lossy().into_owned();
self.lua
.load(&content)
.set_name(name)
.exec()
.map_err(|e| LuaConfigError::Eval {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
/// Evaluate a Lua source string. Primarily for tests; production code
/// goes through [`Self::eval_file`].
#[cfg(test)]
pub fn eval_str(&self, src: &str) -> Result<(), LuaConfigError> {
self.lua.load(src).exec()?;
Ok(())
}
/// Take a snapshot of the accumulated config state. The context can be
/// re-used afterwards (Phase 3.7 hot-reload uses this).
pub fn snapshot(&self) -> LuaConfig {
self.state
.lock()
.expect("lua config state mutex poisoned")
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot_after(src: &str) -> LuaConfig {
let ctx = LuaContext::new().expect("lua context must build");
ctx.eval_str(src).expect("script must run");
ctx.snapshot()
}
#[test]
fn new_context_starts_empty() {
let ctx = LuaContext::new().expect("must build");
let s = ctx.snapshot();
assert!(s.theme.is_none());
assert!(s.providers.is_none());
assert!(s.tabs.is_none());
}
#[test]
fn owlry_is_a_global_table() {
let ctx = LuaContext::new().expect("must build");
ctx.eval_str(
r#"
assert(type(owlry) == "table", "owlry must be a table")
assert(type(owlry.set) == "function", "owlry.set must be a function")
assert(type(owlry.providers) == "function", "owlry.providers must be a function")
assert(type(owlry.tabs) == "function", "owlry.tabs must be a function")
"#,
)
.expect("assertions must pass");
}
#[test]
fn require_returns_the_owlry_table() {
let ctx = LuaContext::new().expect("must build");
ctx.eval_str(
r#"
local o = require("owlry")
assert(o == owlry, "require('owlry') must return the same table as the global")
"#,
)
.expect("require must work");
}
#[test]
fn set_populates_known_keys() {
let s = snapshot_after(
r#"
owlry.set {
theme = "owl",
width = 900,
height = 720,
font_size = 16,
border_radius = 8,
terminal = "kitty",
use_uwsm = true,
show_icons = false,
max_results = 50,
frecency = false,
frecency_weight = 0.75,
search_engine = "google",
}
"#,
);
assert_eq!(s.theme.as_deref(), Some("owl"));
assert_eq!(s.width, Some(900));
assert_eq!(s.height, Some(720));
assert_eq!(s.font_size, Some(16));
assert_eq!(s.border_radius, Some(8));
assert_eq!(s.terminal.as_deref(), Some("kitty"));
assert_eq!(s.use_uwsm, Some(true));
assert_eq!(s.show_icons, Some(false));
assert_eq!(s.max_results, Some(50));
assert_eq!(s.frecency, Some(false));
assert_eq!(s.frecency_weight, Some(0.75));
assert_eq!(s.search_engine.as_deref(), Some("google"));
assert!(s.unknown_settings.is_empty());
}
#[test]
fn set_merges_across_multiple_calls() {
let s = snapshot_after(
r#"
owlry.set { theme = "owl", width = 900 }
owlry.set { font_size = 16 }
owlry.set { width = 1000 }
"#,
);
assert_eq!(s.theme.as_deref(), Some("owl"), "earlier theme must persist");
assert_eq!(s.font_size, Some(16), "second call must apply");
assert_eq!(s.width, Some(1000), "later width must win");
}
#[test]
fn set_records_unknown_keys_without_failing() {
let s = snapshot_after(
r#"
owlry.set { theme = "owl", mystery_2_2_key = 42, another_unknown = "x" }
"#,
);
assert_eq!(s.theme.as_deref(), Some("owl"));
assert!(s.unknown_settings.contains(&"mystery_2_2_key".to_string()));
assert!(s.unknown_settings.contains(&"another_unknown".to_string()));
}
#[test]
fn set_with_wrong_type_returns_error() {
let ctx = LuaContext::new().expect("must build");
let err = ctx
.eval_str(r#"owlry.set { width = "not_a_number" }"#)
.unwrap_err();
assert!(
matches!(err, LuaConfigError::Runtime(_)),
"expected Runtime error, got {:?}",
err
);
}
#[test]
fn providers_captures_id_list() {
let s = snapshot_after(
r#"owlry.providers { "app", "cmd", "calc", "conv", "power" }"#,
);
assert_eq!(
s.providers,
Some(vec![
"app".into(),
"cmd".into(),
"calc".into(),
"conv".into(),
"power".into(),
])
);
}
#[test]
fn providers_called_twice_replaces_the_list() {
let s = snapshot_after(
r#"
owlry.providers { "app", "cmd" }
owlry.providers { "ssh", "emoji" }
"#,
);
assert_eq!(
s.providers,
Some(vec!["ssh".into(), "emoji".into()]),
"second call must fully replace first"
);
}
#[test]
fn tabs_captures_list_preserving_order() {
let s = snapshot_after(r#"owlry.tabs { "app", "cmd", "uuctl" }"#);
assert_eq!(
s.tabs,
Some(vec!["app".into(), "cmd".into(), "uuctl".into()])
);
}
#[test]
fn tabs_called_twice_replaces_the_list() {
let s = snapshot_after(
r#"
owlry.tabs { "app", "cmd" }
owlry.tabs { "ssh" }
"#,
);
assert_eq!(s.tabs, Some(vec!["ssh".into()]));
}
#[test]
fn empty_tabs_list_is_valid() {
let s = snapshot_after(r#"owlry.tabs { }"#);
assert_eq!(s.tabs, Some(vec![]));
}
#[test]
fn snapshot_can_be_taken_multiple_times() {
let ctx = LuaContext::new().expect("must build");
ctx.eval_str(r#"owlry.set { theme = "owl" }"#).expect("run");
let s1 = ctx.snapshot();
let s2 = ctx.snapshot();
assert_eq!(s1.theme, s2.theme);
ctx.eval_str(r#"owlry.set { width = 900 }"#).expect("run");
let s3 = ctx.snapshot();
assert_eq!(s3.theme.as_deref(), Some("owl"));
assert_eq!(s3.width, Some(900));
}
#[test]
fn end_to_end_full_config_example() {
let s = snapshot_after(
r#"
local o = require("owlry")
o.set { theme = "owl", width = 850, font_size = 14 }
o.providers { "app", "cmd", "calc", "conv", "power", "systemd" }
o.tabs { "app", "cmd", "uuctl" }
"#,
);
assert_eq!(s.theme.as_deref(), Some("owl"));
assert_eq!(s.width, Some(850));
assert_eq!(s.font_size, Some(14));
assert_eq!(
s.providers.as_deref(),
Some(&[
"app".to_string(),
"cmd".to_string(),
"calc".to_string(),
"conv".to_string(),
"power".to_string(),
"systemd".to_string(),
][..])
);
assert_eq!(
s.tabs.as_deref(),
Some(&["app".to_string(), "cmd".to_string(), "uuctl".to_string()][..])
);
}
#[test]
fn eval_file_reads_and_runs_a_real_file() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("owlry.lua");
let mut f = std::fs::File::create(&path).expect("create");
writeln!(f, r#"owlry.set {{ theme = "nord", width = 1024 }}"#).expect("write");
drop(f);
let ctx = LuaContext::new().expect("must build");
ctx.eval_file(&path).expect("file must eval");
let s = ctx.snapshot();
assert_eq!(s.theme.as_deref(), Some("nord"));
assert_eq!(s.width, Some(1024));
}
#[test]
fn eval_file_reports_path_on_syntax_error() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("broken.lua");
let mut f = std::fs::File::create(&path).expect("create");
writeln!(f, "this is not valid lua {{{{").expect("write");
drop(f);
let ctx = LuaContext::new().expect("must build");
let err = ctx.eval_file(&path).unwrap_err();
match err {
LuaConfigError::Eval { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected Eval error, got {:?}", other),
}
}
#[test]
fn eval_file_reports_path_on_missing_file() {
let ctx = LuaContext::new().expect("must build");
let missing = Path::new("/definitely/does/not/exist/owlry.lua");
let err = ctx.eval_file(missing).unwrap_err();
match err {
LuaConfigError::Read { path: p, .. } => {
assert_eq!(p, missing);
}
other => panic!("expected Read error, got {:?}", other),
}
}
// ── owlry.theme(...) end-to-end ─────────────────────────────────────────
#[test]
fn theme_string_form_sets_theme_name() {
let s = snapshot_after(r#"owlry.theme("catppuccin-mocha")"#);
assert_eq!(s.theme_name.as_deref(), Some("catppuccin-mocha"));
// Colours and unknowns unaffected.
assert!(s.theme_colors.background.is_none());
assert!(s.unknown_theme_keys.is_empty());
}
#[test]
fn theme_table_form_captures_known_colour_keys() {
let s = snapshot_after(
r##"
owlry.theme {
background = "#1e1e2e",
background_secondary = "#313244",
border = "#45475a",
text = "#cdd6f4",
text_secondary = "#a6adc8",
accent = "#cba6f7",
accent_bright = "#f5c2e7",
badge_app = "#a6e3a1",
badge_power = "#f38ba8",
badge_uuctl = "#9ece6a",
}
"##,
);
assert_eq!(s.theme_colors.background.as_deref(), Some("#1e1e2e"));
assert_eq!(s.theme_colors.accent.as_deref(), Some("#cba6f7"));
assert_eq!(s.theme_colors.badge_app.as_deref(), Some("#a6e3a1"));
assert_eq!(s.theme_colors.badge_power.as_deref(), Some("#f38ba8"));
assert_eq!(s.theme_colors.badge_uuctl.as_deref(), Some("#9ece6a"));
assert!(s.unknown_theme_keys.is_empty());
}
#[test]
fn theme_string_and_table_compose() {
let s = snapshot_after(
r##"
owlry.theme("nord")
owlry.theme { background = "#1e1e2e" }
"##,
);
assert_eq!(s.theme_name.as_deref(), Some("nord"));
assert_eq!(s.theme_colors.background.as_deref(), Some("#1e1e2e"));
}
#[test]
fn theme_table_calls_merge_per_colour_key() {
let s = snapshot_after(
r##"
owlry.theme { background = "#000000", accent = "#ff00ff" }
owlry.theme { accent = "#00ffff" }
"##,
);
assert_eq!(s.theme_colors.background.as_deref(), Some("#000000"));
assert_eq!(s.theme_colors.accent.as_deref(), Some("#00ffff"));
}
#[test]
fn theme_badge_sys_alias_maps_to_badge_power() {
let s = snapshot_after(r##"owlry.theme { badge_sys = "#ff8800" }"##);
assert_eq!(s.theme_colors.badge_power.as_deref(), Some("#ff8800"));
}
#[test]
fn theme_unknown_keys_recorded_without_failing() {
let s = snapshot_after(
r##"
owlry.theme {
background = "#000000",
future_v22_key = "#ffffff",
another_unknown = "x",
}
"##,
);
assert_eq!(s.theme_colors.background.as_deref(), Some("#000000"));
assert!(s.unknown_theme_keys.contains(&"future_v22_key".to_string()));
assert!(s.unknown_theme_keys.contains(&"another_unknown".to_string()));
}
#[test]
fn theme_wrong_argument_type_errors() {
let ctx = LuaContext::new().expect("must build");
let err = ctx.eval_str(r#"owlry.theme(42)"#).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("expected a string") || msg.contains("table"),
"should mention expected shapes; got: {}",
msg
);
}
// ── owlry.profiles { ... } end-to-end ───────────────────────────────────
#[test]
fn profiles_captures_multiple_named_sets() {
let s = snapshot_after(
r#"
owlry.profiles {
dev = { "app", "cmd", "ssh" },
media = { "emoji", "clipboard" },
minimal = { "app" },
}
"#,
);
let map = s.profiles.expect("profiles must be Some");
assert_eq!(map.len(), 3);
assert_eq!(
map.get("dev"),
Some(&vec!["app".to_string(), "cmd".to_string(), "ssh".to_string()])
);
assert_eq!(
map.get("minimal"),
Some(&vec!["app".to_string()])
);
}
#[test]
fn profiles_called_twice_replaces_the_map() {
let s = snapshot_after(
r#"
owlry.profiles { dev = { "app", "cmd" } }
owlry.profiles { media = { "emoji" } }
"#,
);
let map = s.profiles.expect("profiles must be Some");
assert_eq!(map.len(), 1);
assert!(map.contains_key("media"));
assert!(!map.contains_key("dev"), "second call must fully replace");
}
#[test]
fn profiles_with_non_list_value_errors() {
let ctx = LuaContext::new().expect("must build");
let err = ctx
.eval_str(r#"owlry.profiles { broken = "not a list" }"#)
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("owlry.profiles") || msg.contains("list"),
"should indicate list-of-strings expectation; got: {}",
msg
);
}
#[test]
fn owlry_util_is_attached_to_owlry_table() {
// owlry.util.* lands via api::install — verify the sub-table exists
// and a representative function is callable end-to-end.
let ctx = LuaContext::new().expect("must build");
ctx.eval_str(
r#"
assert(type(owlry.util) == "table", "owlry.util must be a table")
assert(type(owlry.util.shell) == "function", "owlry.util.shell must be a function")
assert(type(owlry.util.shell_lines) == "function")
assert(type(owlry.util.read_file) == "function")
assert(type(owlry.util.glob) == "function")
assert(type(owlry.util.env) == "function")
assert(type(owlry.util.hostname) == "function")
assert(owlry.util.shell("echo end-to-end") == "end-to-end")
"#,
)
.expect("util surface must be live");
}
#[test]
fn util_helpers_can_feed_into_user_provider_items() {
// Realistic shape from docs/lua-api.md §3: build items via shell.
let ctx = LuaContext::new().expect("must build");
ctx.eval_str(
r#"
owlry.provider {
id = "hosts",
items = function()
local h = owlry.util.hostname()
return {
{ name = "current host: " .. h, command = "true" },
}
end,
}
"#,
)
.expect("registration must succeed");
let cfg = ctx.snapshot();
assert_eq!(cfg.user_providers.len(), 1);
// Build the LuaProvider, refresh, and assert hostname appeared.
use crate::lua::LuaProvider;
use crate::providers::Provider;
let mut p = LuaProvider::new(cfg.user_providers[0].clone(), ctx.lua_handle());
p.refresh();
let items = p.items();
assert_eq!(items.len(), 1);
assert!(items[0].name.starts_with("current host: "));
assert!(items[0].name.len() > "current host: ".len());
}
#[test]
fn profiles_coerce_numeric_entries_via_lua_tostring() {
// mlua's String::from_lua follows Lua semantics (tostring coercion),
// so `{ "app", 42 }` becomes ["app", "42"]. Document the behaviour
// here so it isn't accidentally tightened — config validate (3.9)
// can flag "42" as an unknown provider id if needed.
let s = snapshot_after(r#"owlry.profiles { dev = { "app", 42 } }"#);
let map = s.profiles.expect("profiles must be Some");
assert_eq!(
map.get("dev"),
Some(&vec!["app".to_string(), "42".to_string()])
);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! `owlry.util.*` — host helpers exposed to user Lua configs.
//!
//! Surface per docs/lua-api.md §5.1. All functions are stateless; they're
//! attached to the `owlry.util` sub-table built by [`build`] and consumed
//! from [`super::api::install`].
//!
//! No sandboxing in 2.1 (per spec §5.3): the user is running their own
//! config file on their own machine — same trust model as `~/.bashrc`.
use std::path::PathBuf;
use log::{debug, warn};
use mlua::{Lua, Table};
/// Construct the `owlry.util` table with every helper registered. Returned
/// to [`super::api::install`] which attaches it to the parent `owlry` table.
pub(crate) fn build(lua: &Lua) -> mlua::Result<Table> {
let util = lua.create_table()?;
util.set(
"shell",
lua.create_function(|_, cmd: String| Ok(shell_capture(&cmd)))?,
)?;
util.set(
"shell_lines",
lua.create_function(|_, cmd: String| {
let out = shell_capture(&cmd);
// Split on \n, drop a trailing empty element so a terminating
// newline doesn't produce a phantom "" item.
let mut lines: Vec<String> = out.split('\n').map(String::from).collect();
if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
lines.pop();
}
Ok(lines)
})?,
)?;
util.set(
"read_file",
lua.create_function(|_, path: String| {
// Per spec: "nil if missing". We extend "missing" to "any I/O
// failure" (permission denied, etc.) so user configs don't have
// to wrap every read in pcall.
Ok(std::fs::read_to_string(&path).ok())
})?,
)?;
util.set(
"glob",
lua.create_function(|_, pattern: String| {
let expanded = expand_tilde(&pattern);
match glob::glob(&expanded) {
Ok(paths) => Ok(paths
.filter_map(|r| r.ok())
.map(|p: PathBuf| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()),
Err(e) => Err(mlua::Error::RuntimeError(format!(
"owlry.util.glob: invalid pattern '{}': {}",
pattern, e
))),
}
})?,
)?;
util.set(
"env",
lua.create_function(|_, (name, default): (String, Option<String>)| {
Ok(std::env::var(&name).ok().or(default))
})?,
)?;
util.set(
"hostname",
lua.create_function(|_, ()| Ok(read_hostname()))?,
)?;
Ok(util)
}
/// Run a shell command via `sh -c`, capturing stdout. Trailing whitespace
/// is stripped — matches the common Lua-side convention (no surprise newlines).
/// Non-zero exit codes are tolerated; the (possibly empty) stdout is still
/// returned, and stderr is surfaced at warn level for debugging.
fn shell_capture(cmd: &str) -> String {
let output = match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
Ok(o) => o,
Err(e) => {
warn!("owlry.util.shell: failed to spawn `sh -c {}`: {}", cmd, e);
return String::new();
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"owlry.util.shell: `{}` exited {} (stderr: {})",
cmd,
output.status,
stderr.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim_end().to_string()
}
/// Expand a leading `~/` to the user's home directory. Other forms
/// (`~user/`, embedded `~`, env-var refs) are left as-is — keep the
/// surface predictable.
fn expand_tilde(pattern: &str) -> String {
if let Some(rest) = pattern.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest).to_string_lossy().into_owned();
}
pattern.to_string()
}
/// Resolve the current hostname via `gethostname(2)`. `libc` is already a
/// hard dep elsewhere, so no extra crate needed. Returns empty string on
/// the unlikely failure path (signature guarantees infallibility on Linux).
fn read_hostname() -> String {
let mut buf = [0u8; 256];
let rc = unsafe {
libc::gethostname(
buf.as_mut_ptr() as *mut std::os::raw::c_char,
buf.len(),
)
};
if rc != 0 {
debug!("owlry.util.hostname: gethostname returned {}", rc);
return String::new();
}
// Strings from gethostname are NUL-terminated within the buffer.
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn util_table(lua: &Lua) -> Table {
build(lua).expect("build util table")
}
fn install_owlry_util(lua: &Lua) {
let owlry = lua.create_table().expect("table");
owlry.set("util", util_table(lua)).expect("set util");
lua.globals().set("owlry", owlry).expect("global");
}
#[test]
fn shell_captures_stdout_trimmed() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: String = lua
.load(r#"return owlry.util.shell("echo hi")"#)
.eval()
.expect("shell");
assert_eq!(out, "hi");
}
#[test]
fn shell_preserves_interior_newlines() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: String = lua
.load(r#"return owlry.util.shell("printf 'a\nb\nc'")"#)
.eval()
.expect("shell");
assert_eq!(out, "a\nb\nc");
}
#[test]
fn shell_lines_splits_into_table_without_trailing_empty() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: Vec<String> = lua
.load(r#"return owlry.util.shell_lines("printf 'a\nb\nc\n'")"#)
.eval()
.expect("shell_lines");
assert_eq!(out, vec!["a", "b", "c"]);
}
#[test]
fn shell_lines_empty_command_yields_empty_table() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: Vec<String> = lua
.load(r#"return owlry.util.shell_lines("true")"#)
.eval()
.expect("shell_lines");
assert_eq!(out, Vec::<String>::new());
}
#[test]
fn read_file_returns_contents_when_present() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("hello.txt");
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(b"hello from owlry").expect("write");
let lua = Lua::new();
install_owlry_util(&lua);
let script = format!(
r#"return owlry.util.read_file("{}")"#,
path.to_string_lossy()
);
let out: Option<String> = lua.load(&script).eval().expect("read_file");
assert_eq!(out.as_deref(), Some("hello from owlry"));
}
#[test]
fn read_file_returns_nil_when_missing() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: Option<String> = lua
.load(r#"return owlry.util.read_file("/definitely/does/not/exist")"#)
.eval()
.expect("read_file");
assert!(out.is_none());
}
#[test]
fn glob_lists_matching_paths() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
for name in ["a.txt", "b.txt", "c.md"] {
let mut f = std::fs::File::create(dir.path().join(name)).expect("create");
f.write_all(b"x").expect("write");
}
let pattern = format!("{}/*.txt", dir.path().to_string_lossy());
let lua = Lua::new();
install_owlry_util(&lua);
let script = format!(r#"return owlry.util.glob("{}")"#, pattern);
let out: Vec<String> = lua.load(&script).eval().expect("glob");
assert_eq!(out.len(), 2, "two .txt files expected, got: {:?}", out);
assert!(out.iter().any(|p| p.ends_with("a.txt")));
assert!(out.iter().any(|p| p.ends_with("b.txt")));
}
#[test]
fn glob_returns_empty_for_no_matches() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: Vec<String> = lua
.load(r#"return owlry.util.glob("/tmp/owlry_definitely_no_match_xyz/*")"#)
.eval()
.expect("glob");
assert!(out.is_empty());
}
#[test]
fn glob_errors_on_invalid_pattern() {
let lua = Lua::new();
install_owlry_util(&lua);
// Range patterns with no `]` are invalid in the glob crate.
let err = lua
.load(r#"return owlry.util.glob("[unterminated")"#)
.eval::<Vec<String>>()
.unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("owlry.util.glob"), "got: {}", msg);
}
#[test]
fn env_returns_value_when_set() {
unsafe { std::env::set_var("OWLRY_TEST_ENV_VAR", "present"); }
let lua = Lua::new();
install_owlry_util(&lua);
let out: Option<String> = lua
.load(r#"return owlry.util.env("OWLRY_TEST_ENV_VAR")"#)
.eval()
.expect("env");
assert_eq!(out.as_deref(), Some("present"));
unsafe { std::env::remove_var("OWLRY_TEST_ENV_VAR"); }
}
#[test]
fn env_returns_nil_when_missing_no_default() {
// Pick a name unlikely to collide; also pre-clear for safety.
unsafe { std::env::remove_var("OWLRY_TEST_MISSING_XYZ"); }
let lua = Lua::new();
install_owlry_util(&lua);
let out: Option<String> = lua
.load(r#"return owlry.util.env("OWLRY_TEST_MISSING_XYZ")"#)
.eval()
.expect("env");
assert!(out.is_none());
}
#[test]
fn env_returns_default_when_missing() {
unsafe { std::env::remove_var("OWLRY_TEST_MISSING_FOR_DEFAULT"); }
let lua = Lua::new();
install_owlry_util(&lua);
let out: Option<String> = lua
.load(r#"return owlry.util.env("OWLRY_TEST_MISSING_FOR_DEFAULT", "fallback")"#)
.eval()
.expect("env");
assert_eq!(out.as_deref(), Some("fallback"));
}
#[test]
fn hostname_returns_non_empty_string() {
let lua = Lua::new();
install_owlry_util(&lua);
let out: String = lua
.load(r#"return owlry.util.hostname()"#)
.eval()
.expect("hostname");
assert!(!out.is_empty(), "hostname must be non-empty");
// Sanity: no embedded NUL bytes.
assert!(!out.contains('\0'));
}
#[test]
fn expand_tilde_resolves_home_prefix() {
if let Some(home) = dirs::home_dir() {
let result = expand_tilde("~/foo/bar");
assert!(result.starts_with(home.to_string_lossy().as_ref()));
assert!(result.ends_with("/foo/bar"));
}
}
#[test]
fn expand_tilde_leaves_non_home_paths_untouched() {
assert_eq!(expand_tilde("/etc/passwd"), "/etc/passwd");
assert_eq!(expand_tilde("relative/path"), "relative/path");
assert_eq!(expand_tilde("~user/foo"), "~user/foo"); // ~user/ not supported
}
}
+425
View File
@@ -0,0 +1,425 @@
//! `owlry config validate` for the Lua side per docs/lua-api.md §8.
//!
//! Categories (with exit codes from spec §8):
//! - **errors** (exit 1) — syntax / eval failures. Returned to the caller
//! as the `LuaConfigError` from `LoadedConfig::load_lua_path`; we don't
//! surface them through [`ValidationReport`] because there's nothing
//! else to check once eval failed.
//! - **warnings** (exit 2) — unknown keys, unknown ids, tabs ⊄ providers,
//! duplicate provider ids, providers compiled out.
//! - **clean** (exit 0) — no warnings.
//!
//! The validator is a pure function over a [`LuaConfig`] snapshot. The
//! provider-id and theme-key tables it consults are the same canonical
//! sets used by [`super::config::apply_providers_list`] and
//! [`super::config::KNOWN_THEME_KEYS`], so a key/id accepted by the
//! merger is never flagged by the validator (and vice-versa).
use super::config::{KNOWN_THEME_KEYS, LuaConfig};
/// Categorised findings from validating an `owlry.lua` snapshot.
#[derive(Debug, Default, Clone)]
pub struct ValidationReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl ValidationReport {
/// Spec §8 exit codes: 0 clean, 1 errors, 2 warnings-only.
pub fn exit_code(&self) -> i32 {
if !self.errors.is_empty() {
1
} else if !self.warnings.is_empty() {
2
} else {
0
}
}
pub fn is_clean(&self) -> bool {
self.errors.is_empty() && self.warnings.is_empty()
}
}
/// Inspect a [`LuaConfig`] snapshot and produce a [`ValidationReport`].
pub fn validate(cfg: &LuaConfig) -> ValidationReport {
let mut report = ValidationReport::default();
let user_ids: Vec<&str> = cfg.user_providers.iter().map(|s| s.id.as_str()).collect();
// owlry.set — unknown keys (recorded at eval time).
for key in &cfg.unknown_settings {
report.warnings.push(format!(
"owlry.set: unknown key `{}` — ignored (forward-compat for 2.2+ keys)",
key
));
}
// owlry.theme — unknown keys (recorded at eval time).
for key in &cfg.unknown_theme_keys {
// KNOWN_THEME_KEYS contains the pre-v2 `badge_sys` alias too; the
// tracker already filters those, so any leftover key is genuinely
// unrecognised.
if !KNOWN_THEME_KEYS.contains(&key.as_str()) {
report.warnings.push(format!(
"owlry.theme: unknown colour key `{}` — ignored",
key
));
}
}
// owlry.providers — unknown ids and compiled-out warnings.
if let Some(list) = &cfg.providers {
for id in list {
match canonical_provider_id(id) {
Some(canon) => {
if !is_compiled_in(canon) {
report.warnings.push(format!(
"owlry.providers: `{}` is not compiled into this build — \
it will be silently dropped at runtime. Rebuild owlry \
with the matching cargo feature to enable it.",
id
));
}
}
None => {
if !user_ids.contains(&id.as_str()) {
report.warnings.push(format!(
"owlry.providers: unknown id `{}` — not a built-in and not \
registered by any `owlry.provider {{ id = … }}` call",
id
));
}
}
}
}
}
// owlry.tabs — unknown ids; entries not in providers list.
if let Some(tabs) = &cfg.tabs {
let enabled_canon: Option<std::collections::HashSet<&str>> =
cfg.providers.as_ref().map(|v| {
v.iter()
.filter_map(|s| canonical_provider_id(s))
.collect()
});
for id in tabs {
let canon = canonical_provider_id(id);
match canon {
Some(c) => {
if let Some(ref set) = enabled_canon
&& !set.contains(c)
{
report.warnings.push(format!(
"owlry.tabs: `{}` is not in owlry.providers — it will \
be dropped from the tab bar",
id
));
}
}
None => {
if !user_ids.contains(&id.as_str()) {
report.warnings.push(format!(
"owlry.tabs: unknown id `{}` — not a built-in and not \
a user-defined provider",
id
));
}
}
}
}
}
// owlry.provider duplicates.
for dup in &cfg.duplicate_user_provider_ids {
report.warnings.push(format!(
"owlry.provider: id `{}` was registered more than once — the last \
definition wins (earlier registrations are dropped)",
dup
));
}
// owlry.profiles — unknown ids inside each profile's mode list.
if let Some(profiles) = &cfg.profiles {
let mut profile_names: Vec<&String> = profiles.keys().collect();
profile_names.sort();
for name in profile_names {
for id in &profiles[name] {
let known = canonical_provider_id(id).is_some()
|| user_ids.contains(&id.as_str());
if !known {
report.warnings.push(format!(
"owlry.profiles.{}: unknown id `{}` — not a built-in and \
not a user-defined provider",
name, id
));
}
}
}
}
report
}
/// Resolve a Lua-side provider id to its canonical form. Mirrors the
/// alias table in [`super::config::apply_providers_list`].
fn canonical_provider_id(id: &str) -> Option<&'static str> {
match id {
"app" | "application" | "applications" => Some("applications"),
"cmd" | "command" | "commands" => Some("commands"),
"calc" | "calculator" => Some("calculator"),
"conv" | "converter" => Some("converter"),
"power" | "sys" | "system" => Some("power"),
"systemd" | "uuctl" => Some("systemd"),
"ssh" => Some("ssh"),
"clipboard" | "clip" => Some("clipboard"),
"emoji" => Some("emoji"),
"filesearch" | "file" => Some("filesearch"),
"websearch" | "web" | "search" => Some("websearch"),
"dmenu" => Some("dmenu"),
_ => None,
}
}
/// Whether the canonical provider id is compiled into the current build.
/// Always-compiled-in ids (applications, commands, calc, conv, power,
/// dmenu) return `true` unconditionally; the rest gate on cargo features.
fn is_compiled_in(canonical: &str) -> bool {
match canonical {
"applications" | "commands" | "calculator" | "converter" | "power"
| "dmenu" => true,
"clipboard" => cfg!(feature = "clipboard"),
"emoji" => cfg!(feature = "emoji"),
"filesearch" => cfg!(feature = "filesearch"),
"ssh" => cfg!(feature = "ssh"),
"systemd" => cfg!(feature = "systemd"),
"websearch" => cfg!(feature = "websearch"),
_ => true, // unknown canonical → not our call
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ThemeColors;
use std::collections::HashMap;
fn empty_cfg() -> LuaConfig {
LuaConfig::default()
}
#[test]
fn clean_config_produces_no_findings() {
let report = validate(&empty_cfg());
assert!(report.is_clean());
assert_eq!(report.exit_code(), 0);
}
#[test]
fn unknown_set_key_becomes_warning() {
let mut cfg = empty_cfg();
cfg.unknown_settings.push("mystery_2_2_key".into());
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("mystery_2_2_key")),
"got: {:?}",
report.warnings
);
}
#[test]
fn unknown_theme_key_becomes_warning() {
let mut cfg = empty_cfg();
cfg.unknown_theme_keys.push("future_palette_token".into());
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("future_palette_token"))
);
}
#[test]
fn unknown_provider_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into(), "fictional".into()]);
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("fictional")),
"got: {:?}",
report.warnings
);
// `app` is recognised and must NOT produce a warning.
assert!(!report.warnings.iter().any(|w| w.contains("`app`")));
}
#[test]
fn user_provider_id_in_providers_list_is_known() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["hs".into()]);
// Pretend `hs` was registered as a user provider.
cfg.user_providers.push(make_spec("hs"));
let report = validate(&cfg);
assert!(report.is_clean(), "got: {:?}", report);
}
#[test]
fn tabs_unknown_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.tabs = Some(vec!["app".into(), "ghost".into()]);
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("ghost")),
"got: {:?}",
report.warnings
);
}
#[test]
fn tabs_entry_not_in_providers_list_is_warning() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into()]);
cfg.tabs = Some(vec!["app".into(), "cmd".into()]); // cmd not enabled
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("cmd") && w.contains("not in owlry.providers")),
"got: {:?}",
report.warnings
);
}
#[test]
fn tabs_user_provider_outside_providers_list_is_ok() {
// User providers auto-join the enabled set, so they don't need to
// appear in owlry.providers. Listing them in tabs should be fine.
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into()]);
cfg.tabs = Some(vec!["app".into(), "hs".into()]);
cfg.user_providers.push(make_spec("hs"));
let report = validate(&cfg);
assert!(report.is_clean(), "got: {:?}", report);
}
#[test]
fn duplicate_provider_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.duplicate_user_provider_ids.push("hs".into());
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("registered more than once")),
"got: {:?}",
report.warnings
);
}
#[test]
fn profile_with_unknown_id_warns_per_id() {
let mut cfg = empty_cfg();
let mut p: HashMap<String, Vec<String>> = HashMap::new();
p.insert("dev".into(), vec!["app".into(), "phantom".into()]);
cfg.profiles = Some(p);
let report = validate(&cfg);
assert!(
report
.warnings
.iter()
.any(|w| w.contains("owlry.profiles.dev") && w.contains("phantom")),
"got: {:?}",
report.warnings
);
}
#[test]
fn pre_v2_aliases_are_known() {
// `sys` and `uuctl` resolve to power and systemd respectively.
// We only assert they're NOT reported as unknown ids — a
// compiled-out warning is environment-dependent (running this
// with `--no-default-features --features lua` omits the systemd
// feature, which legitimately flags uuctl as compiled-out).
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["sys".into(), "uuctl".into()]);
cfg.tabs = Some(vec!["sys".into(), "uuctl".into()]);
let report = validate(&cfg);
let unknown_count = report
.warnings
.iter()
.filter(|w| w.contains("unknown id"))
.count();
assert_eq!(
unknown_count, 0,
"pre-v2 aliases must not be flagged as unknown ids; got: {:?}",
report.warnings
);
}
#[test]
fn compiled_out_provider_is_warning_when_features_dropped() {
// We can't easily flip features at test time, but we can verify
// that ALWAYS-compiled-in ids never produce a "not compiled in"
// warning — that locks in the alias table from regressing.
let mut cfg = empty_cfg();
cfg.providers = Some(vec![
"app".into(),
"cmd".into(),
"calc".into(),
"conv".into(),
"power".into(),
]);
let report = validate(&cfg);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("not compiled into this build")),
"always-on providers must never warn about compile-out; got: {:?}",
report.warnings
);
}
#[test]
fn report_with_multiple_findings_keeps_them_all() {
let mut cfg = empty_cfg();
cfg.unknown_settings.push("a".into());
cfg.unknown_theme_keys.push("b".into());
cfg.providers = Some(vec!["nope".into()]);
cfg.duplicate_user_provider_ids.push("hs".into());
let report = validate(&cfg);
// 4 categories → at least 4 warnings.
assert!(
report.warnings.len() >= 4,
"expected several warnings, got {}: {:?}",
report.warnings.len(),
report.warnings
);
assert_eq!(report.exit_code(), 2);
}
fn make_spec(id: &str) -> super::super::config::LuaProviderSpec {
// Build a minimal LuaProviderSpec for testing. We can't easily
// synthesize an mlua::Function without a Lua context, so we
// borrow one from a throwaway state.
use mlua::Lua;
let lua = Lua::new();
let f = lua
.create_function(|_, ()| Ok(Vec::<mlua::Table>::new()))
.unwrap();
super::super::config::LuaProviderSpec {
id: id.to_string(),
name: None,
prefix: None,
tab_label: None,
icon: None,
search_noun: None,
priority: 0,
dynamic: false,
items_fn: f,
}
}
// ThemeColors / ProfileConfig usage references just to silence
// unused-import warnings when the imports above aren't otherwise hit.
#[allow(dead_code)]
fn _refs() {
let _ = ThemeColors::default();
}
}
+416
View File
@@ -0,0 +1,416 @@
//! `owlry.lua` hot reload.
//!
//! Per docs/lua-api.md §7: the daemon watches the user's `owlry.lua` and,
//! on save, re-evaluates it in a fresh [`super::LuaContext`]. On success
//! the new state hot-swaps into the running daemon. On failure the
//! previous state is kept untouched so a broken save can't bring down a
//! running session — the user is told exactly what went wrong via both
//! the log and a desktop notification (so they don't need to tail the
//! journal to learn their config is broken).
//!
//! The watcher debounces editor save bursts (vim's atomic rename emits
//! several events per save) and only acts on events touching the watched
//! file. The Lua state held in [`crate::server::Server::_lua_ctx`] is
//! atomically replaced; user-provider items continue to point at live
//! Lua functions because [`super::LuaProvider`] holds its own
//! `Arc<Lua>` clone.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use crate::config::{Config, LoadedConfig};
use crate::providers::{Provider, ProviderManager};
use super::{LuaContext, LuaProvider};
/// Window after the first event in a save burst during which further
/// events get coalesced. Empirical: vim/neovim takes ~10ms, JetBrains
/// ~80ms, atomic-rename saves can spread further apart. 200ms is a
/// comfortable upper bound that still feels instant to the user.
const DEBOUNCE: Duration = Duration::from_millis(200);
/// Handle to a running hot-reload watcher. Dropping it stops the watcher
/// thread cleanly (channel closure triggers loop exit).
pub struct ConfigWatcher {
_watcher: RecommendedWatcher,
_thread: thread::JoinHandle<()>,
}
impl ConfigWatcher {
/// Spawn a watcher on `owlry.lua`'s parent directory. We watch the
/// directory rather than the file directly so atomic-rename saves
/// (vim, JetBrains, etc.) are still observed — the inode the watcher
/// is bound to would otherwise vanish on rename.
pub fn spawn(
lua_path: PathBuf,
config: Arc<RwLock<Config>>,
pm: Arc<RwLock<ProviderManager>>,
lua_ctx: Arc<Mutex<Option<LuaContext>>>,
) -> notify::Result<Self> {
let watch_dir = lua_path
.parent()
.ok_or_else(|| notify::Error::generic("owlry.lua path has no parent directory"))?
.to_path_buf();
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
// Drop the result if the receiver is gone — we're shutting down.
let _ = tx.send(res);
})?;
watcher.watch(&watch_dir, RecursiveMode::NonRecursive)?;
info!("hot-reload: watching {}", lua_path.display());
let watched = lua_path.clone();
let handle = thread::spawn(move || {
run_watch_loop(watched, rx, config, pm, lua_ctx);
});
Ok(Self {
_watcher: watcher,
_thread: handle,
})
}
}
/// Long-running event-pump loop. Returns when the channel disconnects
/// (i.e. the watcher was dropped).
fn run_watch_loop(
lua_path: PathBuf,
rx: mpsc::Receiver<notify::Result<Event>>,
config: Arc<RwLock<Config>>,
pm: Arc<RwLock<ProviderManager>>,
lua_ctx: Arc<Mutex<Option<LuaContext>>>,
) {
loop {
let first = match rx.recv() {
Ok(r) => r,
Err(_) => {
info!("hot-reload: watcher channel closed, exiting");
return;
}
};
match first {
Ok(ev) if event_touches(&ev, &lua_path) => { /* fall through */ }
Ok(_) => continue,
Err(e) => {
warn!("hot-reload: watcher backend error: {}", e);
continue;
}
}
// Drain any follow-up events within the debounce window so a
// single editor save doesn't trigger multiple reloads.
let deadline = Instant::now() + DEBOUNCE;
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(remaining) {
Ok(_) => continue,
Err(_) => break,
}
}
reload(&lua_path, &config, &pm, &lua_ctx);
}
}
/// Return true if any path in this event refers to the watched file.
fn event_touches(ev: &Event, lua_path: &Path) -> bool {
ev.paths.iter().any(|p| p == lua_path)
}
/// Re-evaluate `owlry.lua` in a fresh context and, on success, swap the
/// new state into the running daemon. On failure the old state is
/// preserved and the user is told exactly what went wrong.
fn reload(
lua_path: &Path,
config: &Arc<RwLock<Config>>,
pm: &Arc<RwLock<ProviderManager>>,
lua_ctx: &Arc<Mutex<Option<LuaContext>>>,
) {
let loaded = match LoadedConfig::load_lua_path(lua_path) {
Ok(lc) => lc,
Err(e) => {
report_reload_failure(lua_path, e.as_ref());
return;
}
};
let new_ctx = match loaded.lua {
Some(ctx) => ctx,
None => {
// load_lua_path always sets Some on success; this branch is a
// defensive belt-and-braces — only reachable if the helper
// contract changes.
warn!("hot-reload: LoadedConfig had no LuaContext after success");
return;
}
};
// Build the user-provider boxes against the NEW Lua handle BEFORE
// taking any write locks so we hold them as briefly as possible.
let new_user_providers: Vec<Box<dyn Provider>> = loaded
.user_providers
.iter()
.cloned()
.map(|spec| Box::new(LuaProvider::new(spec, new_ctx.lua_handle())) as Box<dyn Provider>)
.collect();
// Swap order matters:
// 1. Config → so any read that races a reload sees consistent
// "new config + maybe old PM" rather than "new PM + old config".
// 2. ProviderManager → rebuild from the freshly-written config plus
// the new user providers.
// 3. Lua context → replace last; the old Box<dyn Provider>s inside
// the dropped PM still held the old Arc<Lua>, so the OLD state
// doesn't actually drop until those go too. The new
// LuaProviders inside new_pm hold the new Arc<Lua>.
{
let mut cfg_guard = match config.write() {
Ok(g) => g,
Err(_) => {
warn!("hot-reload: config lock poisoned; aborting reload");
return;
}
};
*cfg_guard = loaded.config;
}
let new_pm = ProviderManager::new_with_config(Arc::clone(config), new_user_providers);
{
let mut pm_guard = match pm.write() {
Ok(g) => g,
Err(_) => {
warn!("hot-reload: provider lock poisoned; aborting reload");
return;
}
};
*pm_guard = new_pm;
}
{
let mut ctx_guard = match lua_ctx.lock() {
Ok(g) => g,
Err(_) => {
warn!("hot-reload: lua context lock poisoned; aborting reload");
return;
}
};
*ctx_guard = Some(new_ctx);
}
info!("hot-reload: applied {}", lua_path.display());
let _ = notify_rust::Notification::new()
.summary("owlry: config reloaded")
.body(&format!(
"Applied changes from {}",
lua_path.display()
))
.icon("emblem-default")
.timeout(notify_rust::Timeout::Milliseconds(3_000))
.show();
}
/// Surface a reload failure to both the log and the user's desktop. The
/// log entry includes the file path; the notification includes the same
/// path plus the formatted error chain so the user knows what's broken
/// without checking the journal.
fn report_reload_failure(lua_path: &Path, err: &dyn std::error::Error) {
let chain = error_chain(err);
error!(
"hot-reload: re-eval of {} failed, keeping previous config — {}",
lua_path.display(),
chain
);
let _ = notify_rust::Notification::new()
.summary("owlry: config reload failed")
.body(&format!("{}\n\n{}", lua_path.display(), chain))
.icon("dialog-error")
.urgency(notify_rust::Urgency::Normal)
.timeout(notify_rust::Timeout::Milliseconds(8_000))
.show();
}
/// Render the full error chain (`err: <cause>: <cause>: …`) so mlua's
/// line/column information from a syntax error doesn't get truncated.
fn error_chain(err: &dyn std::error::Error) -> String {
let mut parts = vec![err.to_string()];
let mut cur: Option<&dyn std::error::Error> = err.source();
while let Some(c) = cur {
parts.push(c.to_string());
cur = c.source();
}
parts.join(": ")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(contents.as_bytes()).expect("write");
path
}
fn fresh_daemon_state(lua_path: &Path) -> (
Arc<RwLock<Config>>,
Arc<RwLock<ProviderManager>>,
Arc<Mutex<Option<LuaContext>>>,
) {
let loaded = LoadedConfig::load_lua_path(lua_path).expect("initial load");
let ctx = loaded.lua.expect("lua ctx after load");
let user_providers: Vec<Box<dyn Provider>> = loaded
.user_providers
.iter()
.cloned()
.map(|spec| Box::new(LuaProvider::new(spec, ctx.lua_handle())) as Box<dyn Provider>)
.collect();
let config = Arc::new(RwLock::new(loaded.config));
let pm = Arc::new(RwLock::new(ProviderManager::new_with_config(
Arc::clone(&config),
user_providers,
)));
let lua_ctx = Arc::new(Mutex::new(Some(ctx)));
(config, pm, lua_ctx)
}
#[test]
fn reload_swaps_in_new_config_state() {
let dir = tempfile::tempdir().expect("tempdir");
let lua_path = write_file(
dir.path(),
"owlry.lua",
r#"owlry.set { max_results = 7 }"#,
);
let (config, pm, lua_ctx) = fresh_daemon_state(&lua_path);
assert_eq!(config.read().unwrap().general.max_results, 7);
// Rewrite the file and call reload directly.
write_file(
dir.path(),
"owlry.lua",
r#"owlry.set { max_results = 33 }"#,
);
reload(&lua_path, &config, &pm, &lua_ctx);
assert_eq!(config.read().unwrap().general.max_results, 33);
}
#[test]
fn reload_failure_preserves_previous_state() {
let dir = tempfile::tempdir().expect("tempdir");
let lua_path = write_file(
dir.path(),
"owlry.lua",
r#"owlry.set { max_results = 7 }"#,
);
let (config, pm, lua_ctx) = fresh_daemon_state(&lua_path);
assert_eq!(config.read().unwrap().general.max_results, 7);
// Replace the file with broken Lua. Reload must keep the old value.
write_file(dir.path(), "owlry.lua", "this is not valid lua {{{");
reload(&lua_path, &config, &pm, &lua_ctx);
assert_eq!(
config.read().unwrap().general.max_results,
7,
"old config must survive a broken reload"
);
}
#[test]
fn reload_replaces_user_providers_with_new_definitions() {
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
let dir = tempfile::tempdir().expect("tempdir");
let lua_path = write_file(
dir.path(),
"owlry.lua",
r#"
owlry.provider {
id = "v1",
items = function() return { { name = "v1 unique item", command = "echo v1" } } end,
}
"#,
);
let (config, pm, lua_ctx) = fresh_daemon_state(&lua_path);
let filter = ProviderFilter::all();
let freq = FrecencyStore::new();
{
let pm_guard = pm.read().unwrap();
let results = pm_guard.search_with_frecency(
"v1 unique",
100,
&filter,
&freq,
0.0,
None,
);
assert!(
results.iter().any(|(it, _)| it.name.contains("v1 unique")),
"v1 item must be present before reload"
);
}
// Replace v1 with v2 and reload.
write_file(
dir.path(),
"owlry.lua",
r#"
owlry.provider {
id = "v2",
items = function() return { { name = "v2 unique item", command = "echo v2" } } end,
}
"#,
);
reload(&lua_path, &config, &pm, &lua_ctx);
let pm_guard = pm.read().unwrap();
let v2 = pm_guard.search_with_frecency("v2 unique item", 100, &filter, &freq, 0.0, None);
let v1 = pm_guard.search_with_frecency("v1 unique item", 100, &filter, &freq, 0.0, None);
// Use the full "v1 unique item" string to discriminate from the
// websearch provider which synthesises "Search: <query>" items for
// any query (so a partial substring would false-positive).
assert!(
v2.iter().any(|(it, _)| it.name == "v2 unique item"),
"v2 item must appear after reload"
);
assert!(
!v1.iter().any(|(it, _)| it.name == "v1 unique item"),
"v1 item must be gone after reload"
);
}
#[test]
fn event_touches_matches_only_watched_path() {
let watched = PathBuf::from("/tmp/owlry-test/owlry.lua");
let other = PathBuf::from("/tmp/owlry-test/other.txt");
let ev_match = Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Any),
paths: vec![watched.clone()],
attrs: Default::default(),
};
let ev_miss = Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Any),
paths: vec![other.clone()],
attrs: Default::default(),
};
assert!(event_touches(&ev_match, &watched));
assert!(!event_touches(&ev_miss, &watched));
}
#[test]
fn error_chain_renders_nested_causes() {
use std::io;
// io::Error with a custom source so we can verify chaining.
let inner = io::Error::other("disk on fire");
let chain = error_chain(&inner);
assert!(chain.contains("disk on fire"), "got: {}", chain);
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ fn main() {
Some(Command::Doctor) => commands::run_doctor(),
Some(Command::Providers { id }) => commands::run_providers(id),
Some(Command::Config { action }) => commands::run_config(action),
Some(Command::MigrateConfig) => commands::run_migrate_config(),
Some(Command::MigrateConfig { force }) => commands::run_migrate_config(force),
Some(Command::Dmenu { prompt }) => {
init_logger();
commands::run_dmenu(prompt);
+9
View File
@@ -61,6 +61,15 @@ pub fn config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("config.toml"))
}
/// Lua config file: `$XDG_CONFIG_HOME/owlry/owlry.lua`.
///
/// Takes precedence over `config.toml` when both exist (per
/// `docs/lua-api.md` §2 — Lua wins; TOML is ignored entirely in that case).
/// Named `owlry.lua` rather than `init.lua` for brand identity (D23).
pub fn lua_config_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("owlry.lua"))
}
/// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css`
pub fn custom_style_file() -> Option<PathBuf> {
owlry_config_dir().map(|p| p.join("style.css"))
+1 -2
View File
@@ -228,8 +228,7 @@ impl Provider for ApplicationProvider {
);
// Sort alphabetically by name
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.items.sort_by_key(|i| i.name.to_lowercase());
}
fn items(&self) -> &[LaunchItem] {
+1 -2
View File
@@ -99,8 +99,7 @@ impl Provider for CommandProvider {
debug!("Found {} commands in PATH", self.items.len());
// Sort alphabetically
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.items.sort_by_key(|i| i.name.to_lowercase());
}
fn items(&self) -> &[LaunchItem] {
+6
View File
@@ -8,6 +8,12 @@ pub struct DmenuProvider {
enabled: bool,
}
impl Default for DmenuProvider {
fn default() -> Self {
Self::new()
}
}
impl DmenuProvider {
pub fn new() -> Self {
Self {
+22 -9
View File
@@ -271,9 +271,14 @@ impl ProviderManager {
/// Build a ProviderManager for the daemon, sourcing enabled providers from config.
///
/// Only built-in / compiled-in providers are registered here. Future Lua-defined
/// providers (Phase 3+) are added via [`Self::add_provider`] after construction.
pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
/// `user_providers` are appended after the built-ins — typically these
/// are `LuaProvider`s constructed from `owlry.provider {}` registrations
/// (Phase 3.3+). Pass an empty Vec if there are no user providers, or
/// when the `lua` feature is off.
pub fn new_with_config(
config: Arc<RwLock<Config>>,
user_providers: Vec<Box<dyn Provider>>,
) -> Self {
let cfg_snapshot = match config.read() {
Ok(cfg) => cfg.providers.clone(),
Err(_) => {
@@ -313,6 +318,14 @@ impl ProviderManager {
info!("Registered systemd provider (type_id: uuctl)");
}
// Lua-defined user providers join the static set after built-ins.
// They were enumerated in the LuaConfig snapshot during Config load
// and turned into Box<dyn Provider> by the caller (see Server::bind).
if !user_providers.is_empty() {
info!("Registering {} user-defined provider(s)", user_providers.len());
core_providers.extend(user_providers);
}
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if cfg_snapshot.calculator {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
@@ -416,7 +429,7 @@ impl ProviderManager {
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.sort_by_key(|x| std::cmp::Reverse(x.1));
results.truncate(max_results);
results
}
@@ -458,7 +471,7 @@ impl ProviderManager {
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.sort_by_key(|x| std::cmp::Reverse(x.1));
results.truncate(max_results);
results
}
@@ -541,14 +554,14 @@ impl ProviderManager {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
scored_refs.sort_by_key(|x| std::cmp::Reverse(x.1));
results.extend(
scored_refs
.into_iter()
.map(|(item, score)| (item.clone(), score)),
);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.sort_by_key(|x| std::cmp::Reverse(x.1));
results.truncate(max_results);
return results;
}
@@ -617,14 +630,14 @@ impl ProviderManager {
scored_refs.select_nth_unstable_by(max_results, |a, b| b.1.cmp(&a.1));
scored_refs.truncate(max_results);
}
scored_refs.sort_by(|a, b| b.1.cmp(&a.1));
scored_refs.sort_by_key(|x| std::cmp::Reverse(x.1));
results.extend(
scored_refs
.into_iter()
.map(|(item, score)| (item.clone(), score)),
);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.sort_by_key(|x| std::cmp::Reverse(x.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
+1 -2
View File
@@ -128,8 +128,7 @@ impl Provider for SystemdProvider {
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_systemctl_output(&stdout);
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.items.sort_by_key(|i| i.name.to_lowercase());
}
fn items(&self) -> &[LaunchItem] {
+93 -6
View File
@@ -4,6 +4,8 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
#[cfg(feature = "lua")]
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
@@ -80,10 +82,12 @@ fn read_bounded_line(reader: &mut BufReader<UnixStream>, max: usize) -> io::Resu
use log::{error, info, warn};
use crate::config::Config;
#[cfg(feature = "lua")]
use crate::config::LoadedConfig;
use crate::data::FrecencyStore;
use crate::filter::ProviderFilter;
use crate::ipc::{ProviderDesc, Request, Response, ResultItem};
use crate::providers::{LaunchItem, ProviderManager};
use crate::providers::{LaunchItem, Provider, ProviderManager};
/// IPC server that listens on a Unix domain socket and dispatches
/// requests to the provider system.
@@ -93,6 +97,16 @@ pub struct Server {
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<RwLock<Config>>,
/// Keeps the Lua runtime alive for the daemon's lifetime so user
/// providers (their `items` closures) remain callable. Wrapped so the
/// hot-reload watcher (Phase 3.7) can atomically replace the context
/// when `owlry.lua` is saved.
#[cfg(feature = "lua")]
_lua_ctx: Arc<Mutex<Option<crate::lua::LuaContext>>>,
/// Filesystem watcher driving hot reload. Dropping this stops the
/// watcher thread; kept alive for the daemon's lifetime.
#[cfg(feature = "lua")]
_lua_watcher: Option<crate::lua::ConfigWatcher>,
}
impl Server {
@@ -110,16 +124,76 @@ impl Server {
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
info!("IPC server listening on {:?}", socket_path);
let config = Arc::new(RwLock::new(Config::load_or_default()));
let provider_manager = ProviderManager::new_with_config(Arc::clone(&config));
// Resolve config + any Lua-defined user providers in a single pass.
// When the `lua` feature is off the daemon falls back to the
// TOML-only loader used pre-Phase-3.
#[cfg(feature = "lua")]
let (config, user_providers, lua_ctx, lua_path) = {
let loaded = LoadedConfig::load_or_default();
let user_providers: Vec<Box<dyn Provider>> = if let Some(ctx) = &loaded.lua {
loaded
.user_providers
.iter()
.cloned()
.map(|spec| {
Box::new(crate::lua::LuaProvider::new(spec, ctx.lua_handle()))
as Box<dyn Provider>
})
.collect()
} else {
Vec::new()
};
(
Arc::new(RwLock::new(loaded.config)),
user_providers,
Arc::new(Mutex::new(loaded.lua)),
loaded.lua_path,
)
};
#[cfg(not(feature = "lua"))]
let (config, user_providers) = (
Arc::new(RwLock::new(Config::load_or_default())),
Vec::<Box<dyn Provider>>::new(),
);
let provider_manager =
ProviderManager::new_with_config(Arc::clone(&config), user_providers);
let frecency = FrecencyStore::new();
let provider_manager = Arc::new(RwLock::new(provider_manager));
// Spawn the hot-reload watcher when an owlry.lua was actually loaded.
// Disabled silently for TOML/defaults — no Lua → nothing to watch.
#[cfg(feature = "lua")]
let _lua_watcher = match lua_path {
Some(path) => match crate::lua::ConfigWatcher::spawn(
path,
Arc::clone(&config),
Arc::clone(&provider_manager),
Arc::clone(&lua_ctx),
) {
Ok(w) => Some(w),
Err(e) => {
warn!(
"hot-reload disabled: failed to start filesystem watcher — {}",
e
);
None
}
},
None => None,
};
Ok(Self {
listener,
socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(RwLock::new(provider_manager)),
provider_manager,
frecency: Arc::new(RwLock::new(frecency)),
config,
#[cfg(feature = "lua")]
_lua_ctx: lua_ctx,
#[cfg(feature = "lua")]
_lua_watcher,
})
}
@@ -311,11 +385,24 @@ impl Server {
config: &Arc<RwLock<Config>>,
) -> Response {
match request {
Request::Query { text, modes } => {
let filter = match modes {
Request::Query {
text,
modes,
prefix,
} => {
let mut filter = match modes {
Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(),
};
if let Some(prefix_str) = prefix {
// Mirrors UI-side parse_query → set_prefix. Tolerates
// unknown ids by emitting Plugin(prefix_str) — that's
// what the dynamic-prefix-fallback in parse_query does.
use std::str::FromStr;
if let Ok(prefix_type) = crate::providers::ProviderType::from_str(prefix_str) {
filter.set_prefix(Some(prefix_type));
}
}
let (max, weight) = {
let cfg = match config.read() {
Ok(g) => g,
+2
View File
@@ -5,6 +5,7 @@ fn test_query_request_roundtrip() {
let req = Request::Query {
text: "fire".into(),
modes: Some(vec!["app".into(), "cmd".into()]),
prefix: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
@@ -16,6 +17,7 @@ fn test_query_request_without_modes() {
let req = Request::Query {
text: "fire".into(),
modes: None,
prefix: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("modes"));
+1
View File
@@ -92,6 +92,7 @@ fn test_server_handles_query_request() {
let req = Request::Query {
text: "nonexistent_query_xyz".into(),
modes: None,
prefix: None,
};
let resp = roundtrip(&mut stream, &req);
+54 -9
View File
@@ -32,6 +32,10 @@ Optional modes (depend on cargo features at build time):
.TP
.BI \-\-profile " NAME"
Launch the UI with a named profile. Profiles are defined under
.B owlry.profiles
in
.IR ~/.config/owlry/owlry.lua
or
.B [profiles.<NAME>]
in
.IR ~/.config/owlry/config.toml .
@@ -64,18 +68,45 @@ List all registered providers, or print details (icon, prefix, position, tab
label, search noun) for one. Requires a running daemon.
.TP
.B "config validate"
Parse the config file and report errors. Exits 0 on success, 1 on parse failure.
Parse the config file and report errors and warnings. When
.I ~/.config/owlry/owlry.lua
exists it is evaluated and the snapshot is checked for unknown keys,
unknown provider ids, tabs not in
.BR owlry.providers ,
duplicate
.B owlry.provider
ids, and providers compiled out of the running build. Exit codes:
.B 0
clean,
.B 1
on errors (syntax / eval / TOML parse failure),
.B 2
on warnings only.
.TP
.B "config show"
Print the resolved effective configuration (defaults merged with the user file)
as TOML.
.TP
.B migrate-config
Migrate TOML configuration to a future
.B init.lua
format. Stub in 2.0; functional once the Lua config layer lands in a later 2.x
release. See
.IR docs/RESTRUCTURE-V2.md .
.BR "migrate-config " [\fB\-\-force\fR]
Migrate
.I ~/.config/owlry/config.toml
to
.I ~/.config/owlry/owlry.lua
with equivalent settings. Output is deterministic and minimal — only values
differing from the built-in defaults are emitted. Pre-v2 aliases
.RB ( system " \(-> " power ,
.B badge_sys
\(->
.BR badge_power )
are normalised to v2 names. The migrator refuses to overwrite an existing
.B owlry.lua
unless
.B \-\-force
(or
.BR \-f )
is passed. See
.IR docs/lua-api.md
\(sc9 for the full mapping.
.SH ENVIRONMENT
.TP
.B XDG_RUNTIME_DIR
@@ -102,8 +133,19 @@ Auto-detected terminal for items marked as needing a terminal. Overridden by
in the config.
.SH FILES
.TP
.I ~/.config/owlry/owlry.lua
Lua configuration (preferred from 2.1). Takes precedence over
.B config.toml
when both exist; an info log notes this at daemon start. The daemon
hot-reloads on save; broken edits surface as desktop notifications and
keep the previous state alive. See
.IR docs/lua-api.md
for the API.
.TP
.I ~/.config/owlry/config.toml
Main configuration. See
TOML configuration (legacy; ignored when
.B owlry.lua
exists). See
.B owlry config show
for the effective state.
.TP
@@ -122,8 +164,11 @@ IPC Unix socket. See
.B OWLRY_SOCKET
to override.
.TP
.I /usr/share/doc/owlry/owlry.example.lua
Annotated example Lua configuration.
.TP
.I /usr/share/doc/owlry/config.example.toml
Annotated example configuration.
Annotated example TOML configuration.
.TP
.I /usr/share/owlry/themes/
Bundled themes.
+144
View File
@@ -0,0 +1,144 @@
-- ~/.config/owlry/owlry.lua — example configuration.
--
-- Drop this file in place to switch from config.toml to the Lua config
-- layer. owlry.lua takes precedence over config.toml when both exist
-- (info-logged at daemon start). The daemon watches this file and
-- hot-reloads on save; broken edits surface as desktop notifications
-- and keep the previous state alive.
--
-- Spec: https://somegit.dev/Owlibou/owlry/src/branch/main/docs/lua-api.md
-- ──────────────────────────────────────────────────────────────────────
-- 1. Global settings.
-- Every key here is OPTIONAL — defaults apply for anything you omit.
-- ──────────────────────────────────────────────────────────────────────
owlry.set {
theme = "owl", -- bundled themes: owl, catppuccin-mocha,
-- nord, rose-pine, dracula, gruvbox-dark,
-- tokyo-night, solarized-dark, one-dark,
-- apex-neon. Plus any *.css under
-- ~/.config/owlry/themes/.
width = 850,
height = 650,
font_size = 14,
border_radius = 12,
-- terminal = "kitty", -- omit to auto-detect ($TERMINAL → xdg-
-- terminal-exec → DE-native → fallback).
use_uwsm = false, -- launch desktop entries via `uwsm app --`
-- for proper systemd session integration.
show_icons = true,
max_results = 100,
frecency = true, -- boost recently / frequently launched items
frecency_weight = 0.3, -- 0.0 = off, 1.0 = strong
search_engine = "duckduckgo", -- google | duckduckgo | bing |
-- startpage | searxng | brave |
-- ecosia, or a custom "{query}" URL.
}
-- ──────────────────────────────────────────────────────────────────────
-- 2. Enabled providers.
-- Omit this call to enable every built-in provider (the default).
-- Listing a subset disables anything not mentioned. Pre-v2 aliases
-- (sys / system → power; uuctl → systemd) still work.
-- ──────────────────────────────────────────────────────────────────────
owlry.providers {
"app", "cmd", -- core launchers
"calc", "conv", -- = / > triggers
"power", -- shutdown / reboot / lock
"systemd", -- user units (:uuctl)
"ssh", "websearch", "filesearch", -- search-style providers
"emoji", "clipboard", -- pickers
}
-- ──────────────────────────────────────────────────────────────────────
-- 3. Tab bar order (subset of providers).
-- Omit to give every enabled provider a tab. Order is preserved;
-- Ctrl+1 jumps to the first entry, Ctrl+2 the second, and so on.
-- ──────────────────────────────────────────────────────────────────────
owlry.tabs { "app", "cmd", "uuctl" }
-- ──────────────────────────────────────────────────────────────────────
-- 4. Theme overrides.
-- owlry.theme("name") picks a bundled / user theme.
-- owlry.theme {} layers individual colour overrides on top.
-- Both forms compose — call as many times as you like.
-- ──────────────────────────────────────────────────────────────────────
-- owlry.theme {
-- background = "#1e1e2e",
-- background_secondary = "#313244",
-- border = "#45475a",
-- text = "#cdd6f4",
-- text_secondary = "#a6adc8",
-- accent = "#cba6f7",
-- accent_bright = "#f5c2e7",
-- -- Per-provider badges (each is optional):
-- badge_app = "#a6e3a1",
-- badge_cmd = "#fab387",
-- badge_power = "#f38ba8",
-- badge_uuctl = "#9ece6a",
-- }
-- ──────────────────────────────────────────────────────────────────────
-- 5. Named profiles, selected by `owlry --profile <name>`.
-- Each profile overrides the default enabled-provider set for that
-- launch but inherits owlry.set / owlry.theme / owlry.tabs.
-- ──────────────────────────────────────────────────────────────────────
-- owlry.profiles {
-- dev = { "app", "cmd", "ssh" },
-- media = { "emoji", "clipboard" },
-- minimal = { "app" },
-- }
-- ──────────────────────────────────────────────────────────────────────
-- 6. User-defined providers.
-- Register your own search source. Every field except `id` and
-- `items` is optional. `items` is called once at startup and the
-- results cached (dynamic per-keystroke providers land in 2.2).
-- ──────────────────────────────────────────────────────────────────────
-- owlry.provider {
-- id = "hs",
-- prefix = ":hs",
-- tab_label = "Shutdown",
-- icon = "system-shutdown",
-- search_noun = "shutdown actions",
-- items = function()
-- return {
-- { name = "Lock", command = "hyprlock" },
-- { name = "Shutdown", command = "systemctl poweroff" },
-- { name = "Reboot", command = "systemctl reboot" },
-- }
-- end,
-- }
-- ──────────────────────────────────────────────────────────────────────
-- 7. Host helpers (use inside provider `items` callbacks).
-- Available under owlry.util.*:
-- shell(cmd) -> string (stdout, trimmed)
-- shell_lines(cmd) -> {string}
-- read_file(path) -> string | nil
-- glob(pattern) -> {string} (~ expansion supported)
-- env(name, default?) -> string | nil
-- hostname() -> string
-- ──────────────────────────────────────────────────────────────────────
-- owlry.provider {
-- id = "docker-ps",
-- prefix = ":docker",
-- items = function()
-- local lines = owlry.util.shell_lines(
-- "docker ps --format '{{.Names}}\t{{.Image}}'"
-- )
-- local items = {}
-- for _, line in ipairs(lines) do
-- local name, image = line:match("([^\t]+)\t(.+)")
-- if name then
-- items[#items + 1] = {
-- name = name,
-- description = image,
-- command = "docker exec -it " .. name .. " bash",
-- terminal = true,
-- }
-- end
-- end
-- return items
-- end,
-- }