owlry 2.1.0: Lua config layer (Phase 3) #8
@@ -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"]
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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).)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 (D1–D24) 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
-- }
|
||||
Reference in New Issue
Block a user