diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..aa6be2c --- /dev/null +++ b/.cargo/config.toml @@ -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"] diff --git a/CLAUDE.md b/CLAUDE.md index 26cb6b2..614106f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) + 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 ] dmenu mode (reads stdin, prints selection) owlry doctor config + socket + providers status owlry providers [] 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` — `mlua::Function` references don't bump + the Lua refcount, so user providers hold their own `Arc` 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`: diff --git a/Cargo.lock b/Cargo.lock index 180f66d..0adb255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/README.md b/README.md index cf2664e..47a8d25 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ owlry -d run the daemon (alias: `owlry daemon`) owlry dmenu [-p ] dmenu mode (reads stdin, prints selection) owlry doctor diagnostics: config + socket + providers owlry providers [] 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 diff --git a/ROADMAP.md b/ROADMAP.md index 2597dbb..9ba8dd7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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).) diff --git a/aur/owlry/PKGBUILD b/aur/owlry/PKGBUILD index ecb727a..7975556 100644 --- a/aur/owlry/PKGBUILD +++ b/aur/owlry/PKGBUILD @@ -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" diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index 00c00ad..24546bc 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -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", diff --git a/crates/owlry/src/backend.rs b/crates/owlry/src/backend.rs index 042a16f..f0f9f5f 100644 --- a/crates/owlry/src/backend.rs +++ b/crates/owlry/src/backend.rs @@ -18,6 +18,11 @@ pub struct QueryParams { #[allow(dead_code)] pub max_results: usize, pub modes: Option>, + /// 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, pub tag_filter: Option, } @@ -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 { + 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)) diff --git a/crates/owlry/src/cli.rs b/crates/owlry/src/cli.rs index 26417d2..d22fda0 100644 --- a/crates/owlry/src/cli.rs +++ b/crates/owlry/src/cli.rs @@ -24,7 +24,7 @@ EXAMPLES: owlry providers [] 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 }) + )); } } diff --git a/crates/owlry/src/client.rs b/crates/owlry/src/client.rs index 3bd7da1..5462ab1 100644 --- a/crates/owlry/src/client.rs +++ b/crates/owlry/src/client.rs @@ -116,10 +116,21 @@ impl CoreClient { } /// Send a search query and return matching results. - pub fn query(&mut self, text: &str, modes: Option>) -> io::Result> { + /// + /// `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>, + prefix: Option, + ) -> io::Result> { 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) { + 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!( diff --git a/crates/owlry/src/commands.rs b/crates/owlry/src/commands.rs index 7148610..f3cad95 100644 --- a/crates/owlry/src/commands.rs +++ b/crates/owlry/src/commands.rs @@ -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. diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index e89206d..62a1dbc 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -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> { - 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.] entries to [plugin_config.]. - // 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::(&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.]` → + /// `[plugin_config.]` migration. Returns defaults if the file is + /// missing or the path is `None`. + pub(crate) fn load_from_toml( + path: Option<&Path>, + ) -> Result> { + 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.] entries to [plugin_config.]. + // 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::(&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> { + let loaded = LoadedConfig::load_lua_path(lua_path)?; + Ok(loaded.config) + } + pub fn save(&self) -> Result<(), Box> { 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, + pub lua_path: Option, + pub user_providers: Vec, +} + +#[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", &"") + .field("lua", &self.lua.as_ref().map(|_| "")) + .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> { + 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> { + // 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); + } +} diff --git a/crates/owlry/src/filter.rs b/crates/owlry/src/filter.rs index 1c0d7a1..bfe702e 100644 --- a/crates/owlry/src/filter.rs +++ b/crates/owlry/src/filter.rs @@ -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(); diff --git a/crates/owlry/src/ipc.rs b/crates/owlry/src/ipc.rs index c2ab33a..b7f2ade 100644 --- a/crates/owlry/src/ipc.rs +++ b/crates/owlry/src/ipc.rs @@ -7,6 +7,15 @@ pub enum Request { text: String, #[serde(skip_serializing_if = "Option::is_none")] modes: Option>, + /// 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, }, Launch { item_id: String, @@ -73,3 +82,52 @@ pub struct ProviderDesc { #[serde(default, skip_serializing_if = "Option::is_none")] pub search_noun: Option, } + +#[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), + } + } +} diff --git a/crates/owlry/src/lib.rs b/crates/owlry/src/lib.rs index 81b7171..65ab28e 100644 --- a/crates/owlry/src/lib.rs +++ b/crates/owlry/src/lib.rs @@ -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; diff --git a/crates/owlry/src/lua/api.rs b/crates/owlry/src/lua/api.rs new file mode 100644 index 0000000..8f89665 --- /dev/null +++ b/crates/owlry/src/lua/api.rs @@ -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>` 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>) -> 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>, +) -> 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>, +) -> 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>, +) -> 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>, +) -> 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::>("theme")? { + cfg.theme = Some(v); + } + if let Some(v) = t.get::>("width")? { + cfg.width = Some(v); + } + if let Some(v) = t.get::>("height")? { + cfg.height = Some(v); + } + if let Some(v) = t.get::>("font_size")? { + cfg.font_size = Some(v); + } + if let Some(v) = t.get::>("border_radius")? { + cfg.border_radius = Some(v); + } + if let Some(v) = t.get::>("terminal")? { + cfg.terminal = Some(v); + } + if let Some(v) = t.get::>("use_uwsm")? { + cfg.use_uwsm = Some(v); + } + if let Some(v) = t.get::>("show_icons")? { + cfg.show_icons = Some(v); + } + if let Some(v) = t.get::>("max_results")? { + cfg.max_results = Some(v); + } + if let Some(v) = t.get::>("frecency")? { + cfg.frecency = Some(v); + } + if let Some(v) = t.get::>("frecency_weight")? { + cfg.frecency_weight = Some(v); + } + if let Some(v) = t.get::>("search_engine")? { + cfg.search_engine = Some(v); + } + + // Record unknown keys (string-keyed, non-known) for `config validate`. + for pair in t.pairs::() { + 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 = t + .sequence_values::() + .collect::>>()?; + 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 = t + .sequence_values::() + .collect::>>()?; + 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 = t.get("name")?; + let prefix: Option = t.get("prefix")?; + let tab_label: Option = t.get("tab_label")?; + let icon: Option = t.get("icon")?; + let search_noun: Option = t.get("search_noun")?; + let priority: u32 = t.get::>("priority")?.unwrap_or(0); + let dynamic: bool = t.get::>("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>, +) -> 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>, +) -> 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::>(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::>("badge_sys")? + { + cfg.theme_colors.badge_power = Some(v); + } + + // Track unknown theme keys for `owlry config validate`. + for pair in t.pairs::() { + 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 `. 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> = HashMap::new(); + for pair in t.pairs::() { + 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 = modes_tbl + .sequence_values::() + .collect::>>() + .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(()) +} diff --git a/crates/owlry/src/lua/config.rs b/crates/owlry/src/lua/config.rs new file mode 100644 index 0000000..0b9c5a8 --- /dev/null +++ b/crates/owlry/src/lua/config.rs @@ -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`: `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, + pub width: Option, + pub height: Option, + pub font_size: Option, + pub border_radius: Option, + pub terminal: Option, + pub use_uwsm: Option, + pub show_icons: Option, + pub max_results: Option, + pub frecency: Option, + pub frecency_weight: Option, + pub search_engine: Option, + + /// 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, + + /// `None` = `owlry.providers` was never called → keep base defaults. + /// `Some(list)` = explicit set; ids absent from the list are disabled. + pub providers: Option>, + + /// `None` = `owlry.tabs` was never called → keep base defaults. + /// `Some(list)` = explicit set (may be empty to hide all tabs). + pub tabs: Option>, + + /// 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, + + /// 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, + + // ─── 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, + + /// 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, + + // ─── owlry.profiles { ... } ─────────────────────────────────────────── + /// Named profiles from `owlry.profiles { dev = { ... }, ... }`. Each + /// profile maps to a list of provider ids used when `--profile ` + /// 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>>, +} + +/// 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, + pub prefix: Option, + pub tab_label: Option, + pub icon: Option, + pub search_noun: Option, + 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", &"") + .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> = 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")); + } +} diff --git a/crates/owlry/src/lua/error.rs b/crates/owlry/src/lua/error.rs new file mode 100644 index 0000000..1f5095c --- /dev/null +++ b/crates/owlry/src/lua/error.rs @@ -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), +} diff --git a/crates/owlry/src/lua/migrate.rs b/crates/owlry/src/lua/migrate.rs new file mode 100644 index 0000000..871165a --- /dev/null +++ b/crates/owlry/src/lua/migrate.rs @@ -0,0 +1,1119 @@ +//! TOML → `owlry.lua` migration per docs/lua-api.md §9. +//! +//! Reads the user's `config.toml`, parses it through the existing serde +//! pipeline (so pre-v2 aliases like `system`/`badge_sys` already +//! normalise to v2 names), and emits an equivalent `owlry.lua`. +//! +//! Properties guaranteed: +//! - **Deterministic**: every list / map is alphabetically sorted, so +//! running the migrator twice on the same TOML produces byte-identical +//! Lua. Tested in [`tests::migration_is_deterministic`]. +//! - **Minimal**: only values that differ from `Config::default()` are +//! emitted. A TOML file with no explicit settings produces a Lua +//! stub of just the header comment. +//! - **Round-trippable**: the generated Lua, evaluated and merged onto +//! a default Config, must match the source Config's scalar fields. +//! Tested via [`tests::round_trip_preserves_scalars`]. + +use std::io; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::config::Config; + +#[derive(Debug, Error)] +pub enum MigrateError { + #[error("no TOML config to migrate at {0}")] + NoToml(PathBuf), + #[error("destination already exists: {0} — pass --force to overwrite")] + DestExists(PathBuf), + #[error("failed to read {path}")] + Read { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to parse {path}: {message}")] + Parse { path: PathBuf, message: String }, + #[error("failed to write {path}")] + Write { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +/// What the caller asks the migrator to do. +pub struct MigrateRequest<'a> { + pub toml_path: &'a Path, + pub lua_path: &'a Path, + pub force: bool, +} + +/// Summary written to stdout by `owlry migrate-config`. +#[derive(Debug)] +pub struct MigrateOutcome { + pub lua_path: PathBuf, + pub bytes_written: usize, + /// Pre-v2 artifacts that survived in the config dir but can't be + /// auto-migrated — typically C-ABI / Rune plugins from `plugins/` + /// and scripts from the (now-removed) `scripts/` provider. The + /// command handler surfaces these to the user as info notes. + pub legacy_artifacts: Vec, +} + +/// A piece of pre-v2 user content that the migrator detected but didn't +/// translate — the user has to rewrite it (or delete it) by hand. +#[derive(Debug, Clone)] +pub struct LegacyArtifact { + pub path: PathBuf, + pub kind: LegacyKind, +} + +#[derive(Debug, Clone)] +pub enum LegacyKind { + /// A pre-v2 plugin directory (Rune / C-ABI). The plugin.toml fields + /// are surfaced verbatim so the user can paste them into a new + /// `owlry.provider { ... }` block. + Plugin { + id: String, + name: Option, + prefix: Option, + icon: Option, + entry_point: Option, + }, + /// The pre-v2 `scripts/` directory (the standalone `scripts` provider + /// is gone in 2.0; the contents are now user data with no automatic + /// surface). + ScriptsDir { + entries: Vec, + }, +} + +/// Run the full migration: validate paths, parse TOML, emit Lua, write file. +pub fn migrate(req: &MigrateRequest<'_>) -> Result { + if !req.toml_path.exists() { + return Err(MigrateError::NoToml(req.toml_path.to_path_buf())); + } + if req.lua_path.exists() && !req.force { + return Err(MigrateError::DestExists(req.lua_path.to_path_buf())); + } + + let raw_toml = std::fs::read_to_string(req.toml_path).map_err(|e| MigrateError::Read { + path: req.toml_path.to_path_buf(), + source: e, + })?; + let cfg = + Config::load_from_toml(Some(req.toml_path)).map_err(|e| MigrateError::Parse { + path: req.toml_path.to_path_buf(), + message: e.to_string(), + })?; + + let lua = generate_lua(&cfg, &raw_toml, req.toml_path); + + if let Some(parent) = req.lua_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| MigrateError::Write { + path: req.lua_path.to_path_buf(), + source: e, + })?; + } + std::fs::write(req.lua_path, &lua).map_err(|e| MigrateError::Write { + path: req.lua_path.to_path_buf(), + source: e, + })?; + + // Sniff for pre-v2 artifacts in the config dir (plugins/, scripts/). + // These don't block migration — we just surface them so the user + // doesn't silently lose what used to be working providers. + let config_dir = req.toml_path.parent().unwrap_or_else(|| Path::new(".")); + let legacy_artifacts = scan_legacy_artifacts(config_dir); + + Ok(MigrateOutcome { + lua_path: req.lua_path.to_path_buf(), + bytes_written: lua.len(), + legacy_artifacts, + }) +} + +/// Inspect `config_dir` for pre-v2 user data that the migrator can't +/// auto-translate: `plugins//` (Rune / C-ABI) and `scripts/` +/// (the legacy `scripts` provider). Returns an empty Vec when nothing +/// is found, so callers can `if !empty` and short-circuit. +pub fn scan_legacy_artifacts(config_dir: &Path) -> Vec { + let mut out = Vec::new(); + + let plugins_dir = config_dir.join("plugins"); + if plugins_dir.is_dir() { + let mut entries: Vec<_> = std::fs::read_dir(&plugins_dir) + .ok() + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + // Skip dotfile dirs (`.claude`, `.git`, `.cache`, etc.) and any + // directory without a `plugin.toml`. The pre-v2 plugin format + // *always* shipped a manifest — anything without one is just + // user data that happens to live under `plugins/`. + .filter(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + return false; + } + e.path().join("plugin.toml").is_file() + }) + .collect(); + entries.sort_by_key(|e| e.path()); + for entry in entries { + let path = entry.path(); + let kind = parse_plugin_dir(&path); + out.push(LegacyArtifact { path, kind }); + } + } + + let scripts_dir = config_dir.join("scripts"); + if scripts_dir.is_dir() { + let mut entries: Vec = std::fs::read_dir(&scripts_dir) + .ok() + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + entries.sort(); + out.push(LegacyArtifact { + path: scripts_dir, + kind: LegacyKind::ScriptsDir { entries }, + }); + } + + out +} + +/// Best-effort parse of a `plugin.toml` inside a pre-v2 plugin dir. +/// Falls back to using the directory name as the id when the toml is +/// missing or malformed — the goal is informational, not authoritative. +fn parse_plugin_dir(dir: &Path) -> LegacyKind { + let dir_name = dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "?".into()); + + let manifest_path = dir.join("plugin.toml"); + let manifest_str = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(_) => { + return LegacyKind::Plugin { + id: dir_name, + name: None, + prefix: None, + icon: None, + entry_point: None, + }; + } + }; + + let parsed: toml::Value = match toml::from_str(&manifest_str) { + Ok(v) => v, + Err(_) => { + return LegacyKind::Plugin { + id: dir_name, + name: None, + prefix: None, + icon: None, + entry_point: None, + }; + } + }; + + // The pre-v2 manifest format had [plugin] (id/name/entry_point) and + // [[providers]] (id/name/prefix/icon). The first provider's prefix + // and icon are the most useful for a user trying to recreate it. + let plugin = parsed.get("plugin"); + let providers = parsed.get("providers").and_then(|v| v.as_array()); + let first_provider = providers.and_then(|arr| arr.first()); + + let id = plugin + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or(dir_name); + let name = plugin + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .map(String::from); + let entry_point = plugin + .and_then(|p| p.get("entry_point")) + .and_then(|v| v.as_str()) + .map(String::from); + let prefix = first_provider + .and_then(|p| p.get("prefix")) + .and_then(|v| v.as_str()) + .map(String::from); + let icon = first_provider + .and_then(|p| p.get("icon")) + .and_then(|v| v.as_str()) + .map(String::from); + + LegacyKind::Plugin { + id, + name, + prefix, + icon, + entry_point, + } +} + +/// Produce the Lua text for a parsed Config. The `raw_toml` and `src` +/// are only used to render the header comment block — they don't drive +/// any field emission. Pure function: same input → same output bytes. +pub fn generate_lua(cfg: &Config, raw_toml: &str, src: &Path) -> String { + let mut out = String::new(); + + // Header: file provenance + any leading `#` comment block from the + // source TOML (survives the round-trip since serde-derived TOML + // parsing throws everything else away). + out.push_str("-- Generated by `owlry migrate-config` from "); + out.push_str(&src.display().to_string()); + out.push_str(".\n"); + out.push_str( + "-- Edit freely; this file is the canonical owlry config from 2.1 onwards.\n", + ); + out.push_str("-- Spec: https://somegit.dev/Owlibou/owlry/src/branch/main/docs/lua-api.md\n"); + let header_lines = extract_header_comments(raw_toml); + if !header_lines.is_empty() { + out.push_str("--\n-- Preserved from your config.toml:\n"); + for line in header_lines { + out.push_str("-- "); + out.push_str(line.trim_start_matches('#').trim_start()); + out.push('\n'); + } + } + out.push('\n'); + + emit_set_section(&mut out, cfg); + emit_tabs_section(&mut out, cfg); + emit_providers_section(&mut out, cfg); + emit_theme_section(&mut out, cfg); + emit_profiles_section(&mut out, cfg); + + out +} + +// ============================================================================= +// Section emitters +// ============================================================================= + +fn emit_set_section(out: &mut String, cfg: &Config) { + let d = Config::default(); + let mut entries: Vec<(&'static str, String)> = Vec::new(); + + if cfg.appearance.theme != d.appearance.theme + && let Some(ref v) = cfg.appearance.theme + { + entries.push(("theme", lua_string(v))); + } + if cfg.appearance.width != d.appearance.width { + entries.push(("width", cfg.appearance.width.to_string())); + } + if cfg.appearance.height != d.appearance.height { + entries.push(("height", cfg.appearance.height.to_string())); + } + if cfg.appearance.font_size != d.appearance.font_size { + entries.push(("font_size", cfg.appearance.font_size.to_string())); + } + if cfg.appearance.border_radius != d.appearance.border_radius { + entries.push(("border_radius", cfg.appearance.border_radius.to_string())); + } + // terminal: only emit if explicitly set in TOML. The TOML load path + // auto-detects when None, but we only want to emit user intent here. + // We can't distinguish "user set it" from "auto-detect filled it" + // post-load; settle for: emit iff different from None AND from the + // auto-detection chain's heuristic-free default ("xterm"). + if let Some(ref t) = cfg.general.terminal_command + && t != "xterm" + { + entries.push(("terminal", lua_string(t))); + } + if cfg.general.use_uwsm != d.general.use_uwsm { + entries.push(("use_uwsm", cfg.general.use_uwsm.to_string())); + } + if cfg.general.show_icons != d.general.show_icons { + entries.push(("show_icons", cfg.general.show_icons.to_string())); + } + if cfg.general.max_results != d.general.max_results { + entries.push(("max_results", cfg.general.max_results.to_string())); + } + if cfg.providers.frecency != d.providers.frecency { + entries.push(("frecency", cfg.providers.frecency.to_string())); + } + if (cfg.providers.frecency_weight - d.providers.frecency_weight).abs() > f64::EPSILON { + entries.push(("frecency_weight", format!("{}", cfg.providers.frecency_weight))); + } + if cfg.providers.search_engine != d.providers.search_engine { + entries.push(("search_engine", lua_string(&cfg.providers.search_engine))); + } + + if entries.is_empty() { + return; + } + + out.push_str("-- Global settings (only values differing from defaults).\n"); + out.push_str("owlry.set {\n"); + for (k, v) in entries { + out.push_str(" "); + out.push_str(k); + out.push_str(" = "); + out.push_str(&v); + out.push_str(",\n"); + } + out.push_str("}\n\n"); +} + +fn emit_tabs_section(out: &mut String, cfg: &Config) { + let default_tabs = Config::default().general.tabs; + if cfg.general.tabs == default_tabs { + return; + } + out.push_str("-- Tab bar order.\n"); + out.push_str("owlry.tabs { "); + for (i, t) in cfg.general.tabs.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&lua_string(t)); + } + out.push_str(" }\n\n"); +} + +fn emit_providers_section(out: &mut String, cfg: &Config) { + // Build the enabled list using the canonical id for each entry. + let mut enabled: Vec<&'static str> = Vec::new(); + if cfg.providers.applications { + enabled.push("app"); + } + if cfg.providers.commands { + enabled.push("cmd"); + } + if cfg.providers.calculator { + enabled.push("calc"); + } + if cfg.providers.converter { + enabled.push("conv"); + } + if cfg.providers.power { + enabled.push("power"); + } + if cfg.providers.systemd { + enabled.push("systemd"); + } + if cfg.providers.clipboard { + enabled.push("clipboard"); + } + if cfg.providers.emoji { + enabled.push("emoji"); + } + if cfg.providers.filesearch { + enabled.push("filesearch"); + } + if cfg.providers.ssh { + enabled.push("ssh"); + } + if cfg.providers.websearch { + enabled.push("websearch"); + } + + // Skip emission if all providers are enabled (the default). Lua + // semantics treat "no owlry.providers call" identically to "all + // built-ins enabled", so omitting keeps the output minimal. + if enabled.len() == 11 { + return; + } + + enabled.sort_unstable(); + out.push_str( + "-- Enabled providers (omit IDs to disable them; built-ins not listed are off).\n", + ); + out.push_str("owlry.providers {\n"); + for id in enabled { + out.push_str(" "); + out.push_str(&lua_string(id)); + out.push_str(",\n"); + } + out.push_str("}\n\n"); +} + +fn emit_theme_section(out: &mut String, cfg: &Config) { + // Two pieces: name (string-form theme call) and colour overrides + // (table-form theme call). Per spec §4.5 they compose, so we emit + // both when both are set. + let has_name = cfg.appearance.theme.is_some(); + let colours = collect_set_colours(cfg); + if !has_name && colours.is_empty() { + return; + } + + // The `theme = ...` scalar already lives in owlry.set, but the + // string-form owlry.theme(name) is the §4.5-blessed surface. Emit + // theme(name) here too only if NOT already in owlry.set — i.e. we + // don't double-emit. Currently emit_set already handles theme via + // the scalar key, so we skip the name form here to keep the output + // single-sourced. + + if !colours.is_empty() { + out.push_str("-- Theme colour overrides.\n"); + out.push_str("owlry.theme {\n"); + for (key, value) in colours { + out.push_str(" "); + out.push_str(key); + out.push_str(" = "); + out.push_str(&lua_string(&value)); + out.push_str(",\n"); + } + out.push_str("}\n\n"); + } +} + +fn collect_set_colours(cfg: &Config) -> Vec<(&'static str, String)> { + let c = &cfg.appearance.colors; + macro_rules! push_if_set { + ($vec:ident, $cfg_field:ident, $name:expr) => { + if let Some(ref v) = c.$cfg_field { + $vec.push(($name, v.clone())); + } + }; + } + let mut v = Vec::new(); + push_if_set!(v, background, "background"); + push_if_set!(v, background_secondary, "background_secondary"); + push_if_set!(v, border, "border"); + push_if_set!(v, text, "text"); + push_if_set!(v, text_secondary, "text_secondary"); + push_if_set!(v, accent, "accent"); + push_if_set!(v, accent_bright, "accent_bright"); + push_if_set!(v, badge_app, "badge_app"); + push_if_set!(v, badge_bookmark, "badge_bookmark"); + push_if_set!(v, badge_calc, "badge_calc"); + push_if_set!(v, badge_clip, "badge_clip"); + push_if_set!(v, badge_cmd, "badge_cmd"); + push_if_set!(v, badge_dmenu, "badge_dmenu"); + push_if_set!(v, badge_emoji, "badge_emoji"); + push_if_set!(v, badge_file, "badge_file"); + push_if_set!(v, badge_script, "badge_script"); + push_if_set!(v, badge_ssh, "badge_ssh"); + push_if_set!(v, badge_power, "badge_power"); + push_if_set!(v, badge_uuctl, "badge_uuctl"); + push_if_set!(v, badge_web, "badge_web"); + push_if_set!(v, badge_media, "badge_media"); + push_if_set!(v, badge_weather, "badge_weather"); + push_if_set!(v, badge_pomo, "badge_pomo"); + v.sort_by_key(|(k, _)| *k); + v +} + +fn emit_profiles_section(out: &mut String, cfg: &Config) { + if cfg.profiles.is_empty() { + return; + } + + out.push_str("-- Named profiles selected by `owlry --profile `.\n"); + out.push_str("owlry.profiles {\n"); + let mut names: Vec<&String> = cfg.profiles.keys().collect(); + names.sort(); + for name in names { + let profile = &cfg.profiles[name]; + out.push_str(" "); + emit_table_key(out, name); + out.push_str(" = { "); + for (i, m) in profile.modes.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&lua_string(m)); + } + out.push_str(" },\n"); + } + out.push_str("}\n\n"); +} + +/// Emit a Lua table key — bare identifier for valid names, `["…"]` +/// bracket form for anything that isn't (`my-profile`, names with +/// spaces, names starting with a digit, reserved words). Without this, +/// `owlry.profiles { my-profile = { ... } }` would be a Lua syntax error. +fn emit_table_key(out: &mut String, name: &str) { + if is_valid_lua_identifier(name) { + out.push_str(name); + } else { + out.push('['); + out.push_str(&lua_string(name)); + out.push(']'); + } +} + +fn is_valid_lua_identifier(s: &str) -> bool { + if s.is_empty() { + return false; + } + let mut chars = s.chars(); + let first = chars.next().unwrap(); + if !(first.is_ascii_alphabetic() || first == '_') { + return false; + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return false; + } + // Reserved words in Lua 5.4. Bracket-quote them to be safe. + !matches!( + s, + "and" | "break" | "do" | "else" | "elseif" | "end" | "false" + | "for" | "function" | "goto" | "if" | "in" | "local" | "nil" + | "not" | "or" | "repeat" | "return" | "then" | "true" + | "until" | "while" + ) +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/// Render a Rust string as a Lua double-quoted string with `\` and `"` +/// escaped. Non-ASCII bytes are passed through; Lua 5.4 strings are +/// 8-bit clean. +fn lua_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Pull the leading comment block from a TOML file — consecutive lines +/// starting with `#` (or empty) at the very top. Stops at the first +/// non-comment, non-empty line. +fn extract_header_comments(toml: &str) -> Vec { + let mut out = Vec::new(); + for line in toml.lines() { + let t = line.trim_start(); + if t.starts_with('#') { + out.push(line.to_string()); + } else if t.is_empty() { + if !out.is_empty() { + out.push(line.to_string()); + } + } else { + break; + } + } + // Strip a trailing blank line for tidiness. + while out.last().is_some_and(|l| l.trim().is_empty()) { + out.pop(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ProfileConfig; + use std::io::Write; + + fn write_toml(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("config.toml"); + let mut f = std::fs::File::create(&path).expect("create"); + f.write_all(contents.as_bytes()).expect("write"); + path + } + + #[test] + fn lua_string_escapes_special_chars() { + assert_eq!(lua_string("plain"), "\"plain\""); + assert_eq!(lua_string("with \"quotes\""), "\"with \\\"quotes\\\"\""); + assert_eq!(lua_string("back\\slash"), "\"back\\\\slash\""); + assert_eq!(lua_string("new\nline"), "\"new\\nline\""); + assert_eq!(lua_string("ütf8 — €"), "\"ütf8 — €\""); + } + + #[test] + fn header_comments_extracted_until_first_real_line() { + let toml = "# top line\n# more text\n# third\n\n[providers]\napp = true\n"; + let lines = extract_header_comments(toml); + assert_eq!( + lines, + vec!["# top line", "# more text", "# third"] + ); + } + + #[test] + fn default_config_emits_only_header() { + let cfg = Config::default(); + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + // No owlry.set, owlry.tabs, owlry.providers, owlry.theme, owlry.profiles. + assert!(!lua.contains("owlry.set")); + assert!(!lua.contains("owlry.tabs")); + assert!(!lua.contains("owlry.providers")); + assert!(!lua.contains("owlry.theme")); + assert!(!lua.contains("owlry.profiles")); + assert!(lua.contains("Generated by `owlry migrate-config`")); + } + + #[test] + fn set_emits_only_modified_scalars() { + let mut cfg = Config::default(); + cfg.appearance.width = 1024; + cfg.general.max_results = 50; + // height stays at default — must not appear. + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + assert!(lua.contains("width = 1024")); + assert!(lua.contains("max_results = 50")); + assert!(!lua.contains("height")); + assert!(!lua.contains("font_size")); + } + + #[test] + fn providers_section_omitted_when_all_enabled() { + let cfg = Config::default(); // all true + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + assert!(!lua.contains("owlry.providers")); + } + + #[test] + fn providers_section_emits_alphabetically() { + let mut cfg = Config::default(); + cfg.providers.ssh = false; + cfg.providers.emoji = false; + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + let provider_section = lua + .split("owlry.providers {") + .nth(1) + .expect("must have providers block"); + let pos_app = provider_section.find("\"app\"").expect("app"); + let pos_cmd = provider_section.find("\"cmd\"").expect("cmd"); + let pos_systemd = provider_section.find("\"systemd\"").expect("systemd"); + assert!(pos_app < pos_cmd && pos_cmd < pos_systemd); + // ssh and emoji must be absent. + assert!(!provider_section.contains("\"ssh\"")); + assert!(!provider_section.contains("\"emoji\"")); + } + + #[test] + fn theme_colours_emit_alphabetically() { + let mut cfg = Config::default(); + cfg.appearance.colors.accent = Some("#ff00ff".into()); + cfg.appearance.colors.background = Some("#000000".into()); + cfg.appearance.colors.badge_app = Some("#a6e3a1".into()); + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + let section = lua + .split("owlry.theme {") + .nth(1) + .expect("theme block"); + let pos_accent = section.find("accent =").unwrap(); + let pos_bg = section.find("background =").unwrap(); + let pos_badge = section.find("badge_app =").unwrap(); + assert!(pos_accent < pos_bg && pos_bg < pos_badge); + } + + #[test] + fn profiles_section_emits_alphabetically() { + let mut cfg = Config::default(); + cfg.profiles.insert( + "minimal".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + cfg.profiles.insert( + "dev".into(), + ProfileConfig { + modes: vec!["app".into(), "cmd".into(), "ssh".into()], + }, + ); + let lua = generate_lua(&cfg, "", Path::new("/dev/null")); + let pos_dev = lua.find("dev = {").expect("dev"); + let pos_minimal = lua.find("minimal = {").expect("minimal"); + assert!(pos_dev < pos_minimal, "dev must come before minimal"); + } + + #[test] + fn migration_is_deterministic() { + // Run generate_lua twice on the same config; bytes must match. + let mut cfg = Config::default(); + cfg.appearance.theme = Some("nord".into()); + cfg.providers.ssh = false; + cfg.providers.emoji = false; + cfg.appearance.colors.background = Some("#1e1e2e".into()); + cfg.profiles.insert( + "dev".into(), + ProfileConfig { + modes: vec!["app".into(), "cmd".into()], + }, + ); + let a = generate_lua(&cfg, "", Path::new("/x")); + let b = generate_lua(&cfg, "", Path::new("/x")); + assert_eq!(a, b); + } + + #[test] + fn migrate_refuses_existing_dest_without_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml_path = write_toml(dir.path(), "[providers]\napp = true\n"); + let lua_path = dir.path().join("owlry.lua"); + std::fs::write(&lua_path, "-- existing").expect("write existing"); + + let req = MigrateRequest { + toml_path: &toml_path, + lua_path: &lua_path, + force: false, + }; + match migrate(&req) { + Err(MigrateError::DestExists(p)) => assert_eq!(p, lua_path), + other => panic!("expected DestExists, got {:?}", other), + } + // Existing file untouched. + let after = std::fs::read_to_string(&lua_path).unwrap(); + assert_eq!(after, "-- existing"); + } + + #[test] + fn migrate_overwrites_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml_path = write_toml( + dir.path(), + "[appearance]\ntheme = \"nord\"\n", + ); + let lua_path = dir.path().join("owlry.lua"); + std::fs::write(&lua_path, "-- old").expect("write old"); + + let req = MigrateRequest { + toml_path: &toml_path, + lua_path: &lua_path, + force: true, + }; + let outcome = migrate(&req).expect("migrate must succeed"); + assert_eq!(outcome.lua_path, lua_path); + + let after = std::fs::read_to_string(&lua_path).unwrap(); + assert!(after.contains(r#"theme = "nord""#)); + assert!(!after.contains("-- old")); + } + + #[test] + fn migrate_refuses_when_toml_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml_path = dir.path().join("does-not-exist.toml"); + let lua_path = dir.path().join("owlry.lua"); + let req = MigrateRequest { + toml_path: &toml_path, + lua_path: &lua_path, + force: false, + }; + match migrate(&req) { + Err(MigrateError::NoToml(p)) => assert_eq!(p, toml_path), + other => panic!("expected NoToml, got {:?}", other), + } + } + + #[test] + fn pre_v2_aliases_normalise_to_v2_names() { + // TOML uses pre-v2 names (`system` for power, `badge_sys` for badge_power). + let dir = tempfile::tempdir().expect("tempdir"); + let toml_path = write_toml( + dir.path(), + r##" +[providers] +system = false + +[appearance.colors] +badge_sys = "#ff8800" +"##, + ); + let lua_path = dir.path().join("owlry.lua"); + let req = MigrateRequest { + toml_path: &toml_path, + lua_path: &lua_path, + force: false, + }; + migrate(&req).expect("migrate"); + let lua = std::fs::read_to_string(&lua_path).unwrap(); + // serde alias means parsed Config carries `power=false`, `badge_power=Some(...)`. + // Emit must use v2 names. + let providers = lua.split("owlry.providers {").nth(1).unwrap(); + assert!(!providers.contains("\"system\""), "must not emit pre-v2 'system'"); + assert!(!providers.contains("\"sys\"")); + assert!(!providers.contains("\"power\""), "power was disabled"); + let theme = lua.split("owlry.theme {").nth(1).unwrap(); + assert!(theme.contains("badge_power ="), "must emit v2 badge_power"); + assert!(!theme.contains("badge_sys")); + } + + #[test] + fn profile_names_with_hyphens_get_bracket_quoted() { + let mut cfg = Config::default(); + cfg.profiles.insert( + "my-dev".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + cfg.profiles.insert( + "media center".into(), + ProfileConfig { + modes: vec!["emoji".into()], + }, + ); + let lua = generate_lua(&cfg, "", Path::new("/x")); + assert!( + lua.contains(r#"["my-dev"] = { "#), + "hyphenated key must be bracket-quoted; got:\n{}", + lua + ); + assert!( + lua.contains(r#"["media center"] = { "#), + "spaced key must be bracket-quoted; got:\n{}", + lua + ); + } + + #[test] + fn lua_reserved_words_as_keys_get_bracket_quoted() { + let mut cfg = Config::default(); + cfg.profiles.insert( + "end".into(), + ProfileConfig { + modes: vec!["app".into()], + }, + ); + let lua = generate_lua(&cfg, "", Path::new("/x")); + assert!(lua.contains(r#"["end"] = { "#)); + } + + #[test] + fn round_trip_preserves_scalars() { + // End-to-end: TOML → generate_lua → eval through LuaContext → merge + // onto fresh Config → must match the original scalar fields. + let mut original = Config::default(); + original.appearance.theme = Some("catppuccin-mocha".into()); + original.appearance.width = 900; + original.general.max_results = 25; + original.providers.frecency_weight = 0.5; + original.providers.ssh = false; + original.appearance.colors.accent = Some("#cba6f7".into()); + original.profiles.insert( + "dev".into(), + ProfileConfig { + modes: vec!["app".into(), "cmd".into()], + }, + ); + + let lua = generate_lua(&original, "", Path::new("/x")); + + let dir = tempfile::tempdir().expect("tempdir"); + let lua_path = dir.path().join("owlry.lua"); + std::fs::write(&lua_path, &lua).expect("write"); + + let ctx = crate::lua::LuaContext::new().expect("ctx"); + ctx.eval_file(&lua_path).expect("eval must succeed"); + let snapshot = ctx.snapshot(); + let mut rebuilt = Config::default(); + snapshot.merge_into(&mut rebuilt); + + assert_eq!(rebuilt.appearance.theme, original.appearance.theme); + assert_eq!(rebuilt.appearance.width, original.appearance.width); + assert_eq!(rebuilt.general.max_results, original.general.max_results); + assert!( + (rebuilt.providers.frecency_weight - original.providers.frecency_weight).abs() + < f64::EPSILON + ); + assert_eq!(rebuilt.providers.ssh, original.providers.ssh); + assert_eq!( + rebuilt.appearance.colors.accent, + original.appearance.colors.accent + ); + assert_eq!(rebuilt.profiles.len(), original.profiles.len()); + assert_eq!( + rebuilt.profiles["dev"].modes, + original.profiles["dev"].modes + ); + } + + // ── Legacy artifact detection ─────────────────────────────────────────── + + fn write(path: &Path, contents: &str) { + if let Some(p) = path.parent() { + std::fs::create_dir_all(p).expect("mkdir -p"); + } + let mut f = std::fs::File::create(path).expect("create"); + f.write_all(contents.as_bytes()).expect("write"); + } + + #[test] + fn scan_returns_empty_when_no_legacy_dirs() { + let dir = tempfile::tempdir().expect("tempdir"); + let artifacts = scan_legacy_artifacts(dir.path()); + assert!(artifacts.is_empty()); + } + + #[test] + fn scan_detects_plugin_with_full_manifest() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + &dir.path().join("plugins/hyprshutdown/plugin.toml"), + r#" +[plugin] +id = "hyprshutdown" +name = "Hyprshutdown" +version = "0.1.0" +entry_point = "main.rn" + +[[providers]] +id = "hyprshutdown" +prefix = ":hs" +icon = "system-shutdown" +"#, + ); + write(&dir.path().join("plugins/hyprshutdown/main.rn"), "// rune"); + + let artifacts = scan_legacy_artifacts(dir.path()); + assert_eq!(artifacts.len(), 1); + match &artifacts[0].kind { + LegacyKind::Plugin { + id, + name, + prefix, + icon, + entry_point, + } => { + assert_eq!(id, "hyprshutdown"); + assert_eq!(name.as_deref(), Some("Hyprshutdown")); + assert_eq!(prefix.as_deref(), Some(":hs")); + assert_eq!(icon.as_deref(), Some("system-shutdown")); + assert_eq!(entry_point.as_deref(), Some("main.rn")); + } + other => panic!("expected Plugin, got {:?}", other), + } + } + + #[test] + fn scan_skips_directories_without_plugin_toml() { + // A dir under plugins/ with no manifest is just user data + // (editor configs, doc drafts, etc.) — not a real plugin. + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(dir.path().join("plugins/orphan")).expect("mkdir"); + std::fs::create_dir_all(dir.path().join("plugins/docs")).expect("mkdir"); + + let artifacts = scan_legacy_artifacts(dir.path()); + assert!( + artifacts.is_empty(), + "dirs without plugin.toml must not be flagged; got: {:?}", + artifacts + ); + } + + #[test] + fn scan_skips_dotfile_directories() { + // .claude, .git, .cache etc. are never plugins. + let dir = tempfile::tempdir().expect("tempdir"); + write( + &dir.path().join("plugins/.claude/plugin.toml"), + "[plugin]\nid = \".claude\"\n", + ); + let artifacts = scan_legacy_artifacts(dir.path()); + assert!( + artifacts.is_empty(), + "dotfile dirs must be skipped even with a manifest; got: {:?}", + artifacts + ); + } + + #[test] + fn scan_tolerates_malformed_manifest() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + &dir.path().join("plugins/broken/plugin.toml"), + "this is not = valid toml [[", + ); + + let artifacts = scan_legacy_artifacts(dir.path()); + assert_eq!(artifacts.len(), 1); + match &artifacts[0].kind { + LegacyKind::Plugin { id, name, prefix, .. } => { + assert_eq!(id, "broken"); + assert!(name.is_none()); + assert!(prefix.is_none()); + } + other => panic!("expected Plugin, got {:?}", other), + } + } + + #[test] + fn scan_detects_scripts_directory_with_listing() { + let dir = tempfile::tempdir().expect("tempdir"); + write(&dir.path().join("scripts/foo.sh"), "#!/bin/sh\n"); + write(&dir.path().join("scripts/bar.sh"), "#!/bin/sh\n"); + + let artifacts = scan_legacy_artifacts(dir.path()); + assert_eq!(artifacts.len(), 1); + match &artifacts[0].kind { + LegacyKind::ScriptsDir { entries } => { + assert_eq!(entries.len(), 2); + assert!(entries.contains(&"foo.sh".to_string())); + assert!(entries.contains(&"bar.sh".to_string())); + } + other => panic!("expected ScriptsDir, got {:?}", other), + } + } + + #[test] + fn scan_reports_both_plugins_and_scripts_when_present() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + &dir.path().join("plugins/p1/plugin.toml"), + r#"[plugin] id = "p1""#, + ); + write(&dir.path().join("scripts/x.sh"), "#!/bin/sh\n"); + + let artifacts = scan_legacy_artifacts(dir.path()); + assert_eq!(artifacts.len(), 2); + let kinds: Vec<&str> = artifacts + .iter() + .map(|a| match a.kind { + LegacyKind::Plugin { .. } => "plugin", + LegacyKind::ScriptsDir { .. } => "scripts", + }) + .collect(); + assert!(kinds.contains(&"plugin")); + assert!(kinds.contains(&"scripts")); + } + + #[test] + fn migrate_outcome_includes_legacy_artifacts_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml_path = write_toml(dir.path(), "[appearance]\ntheme = \"owl\"\n"); + let lua_path = dir.path().join("owlry.lua"); + write( + &dir.path().join("plugins/hs/plugin.toml"), + "[plugin]\nid = \"hs\"\n", + ); + + let req = MigrateRequest { + toml_path: &toml_path, + lua_path: &lua_path, + force: false, + }; + let outcome = migrate(&req).expect("migrate"); + assert_eq!(outcome.legacy_artifacts.len(), 1); + match &outcome.legacy_artifacts[0].kind { + LegacyKind::Plugin { id, .. } => assert_eq!(id, "hs"), + other => panic!("expected Plugin, got {:?}", other), + } + } +} diff --git a/crates/owlry/src/lua/mod.rs b/crates/owlry/src/lua/mod.rs new file mode 100644 index 0000000..3059ad0 --- /dev/null +++ b/crates/owlry/src/lua/mod.rs @@ -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; diff --git a/crates/owlry/src/lua/provider.rs b/crates/owlry/src/lua/provider.rs new file mode 100644 index 0000000..2a9cdff --- /dev/null +++ b/crates/owlry/src/lua/provider.rs @@ -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, + cached: Vec, +} + +impl LuaProvider { + pub fn new(spec: LuaProviderSpec, lua: Arc) -> 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::>("") { + 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 { + 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 = t.get("description")?; + let icon: Option = t.get("icon")?; + let terminal: bool = t.get::>("terminal")?.unwrap_or(false); + let tags: Vec = t + .get::>("tags")? + .map(|tt| tt.sequence_values::().collect::>>()) + .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` 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 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` where `Provider: Send + Sync`. + fn assert() {} + assert::(); + } + + #[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 { + 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); + } +} diff --git a/crates/owlry/src/lua/runtime.rs b/crates/owlry/src/lua/runtime.rs new file mode 100644 index 0000000..ec6ec7e --- /dev/null +++ b/crates/owlry/src/lua/runtime.rs @@ -0,0 +1,562 @@ +//! Lua runtime wrapper. +//! +//! [`LuaContext`] owns a single `mlua::Lua` state with the `owlry.*` API +//! installed and an `Arc>` 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` 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, + state: Arc>, +} + +impl LuaContext { + /// Build a fresh Lua state with the `owlry.*` API surface installed. + pub fn new() -> Result { + 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 { + 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()]) + ); + } +} diff --git a/crates/owlry/src/lua/util.rs b/crates/owlry/src/lua/util.rs new file mode 100644 index 0000000..cd190e9 --- /dev/null +++ b/crates/owlry/src/lua/util.rs @@ -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
{ + 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 = 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::>()), + Err(e) => Err(mlua::Error::RuntimeError(format!( + "owlry.util.glob: invalid pattern '{}': {}", + pattern, e + ))), + } + })?, + )?; + + util.set( + "env", + lua.create_function(|_, (name, default): (String, Option)| { + 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 = 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 = lua + .load(r#"return owlry.util.shell_lines("true")"#) + .eval() + .expect("shell_lines"); + assert_eq!(out, Vec::::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 = 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 = 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 = 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 = 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::>() + .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 = 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 = 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 = 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 + } +} diff --git a/crates/owlry/src/lua/validate.rs b/crates/owlry/src/lua/validate.rs new file mode 100644 index 0000000..8eb9722 --- /dev/null +++ b/crates/owlry/src/lua/validate.rs @@ -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, + pub warnings: Vec, +} + +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> = + 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> = 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::::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(); + } +} diff --git a/crates/owlry/src/lua/watcher.rs b/crates/owlry/src/lua/watcher.rs new file mode 100644 index 0000000..06c6a36 --- /dev/null +++ b/crates/owlry/src/lua/watcher.rs @@ -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` 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>, + pm: Arc>, + lua_ctx: Arc>>, + ) -> notify::Result { + 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::>(); + 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>, + config: Arc>, + pm: Arc>, + lua_ctx: Arc>>, +) { + 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>, + pm: &Arc>, + lua_ctx: &Arc>>, +) { + 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> = loaded + .user_providers + .iter() + .cloned() + .map(|spec| Box::new(LuaProvider::new(spec, new_ctx.lua_handle())) as Box) + .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 Boxs inside + // the dropped PM still held the old Arc, so the OLD state + // doesn't actually drop until those go too. The new + // LuaProviders inside new_pm hold the new Arc. + { + 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: : : …`) 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>, + Arc>, + Arc>>, + ) { + let loaded = LoadedConfig::load_lua_path(lua_path).expect("initial load"); + let ctx = loaded.lua.expect("lua ctx after load"); + let user_providers: Vec> = loaded + .user_providers + .iter() + .cloned() + .map(|spec| Box::new(LuaProvider::new(spec, ctx.lua_handle())) as Box) + .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: " 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); + } +} diff --git a/crates/owlry/src/main.rs b/crates/owlry/src/main.rs index 10e1cb0..b03ffa9 100644 --- a/crates/owlry/src/main.rs +++ b/crates/owlry/src/main.rs @@ -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); diff --git a/crates/owlry/src/paths.rs b/crates/owlry/src/paths.rs index 9079eb2..f15c128 100644 --- a/crates/owlry/src/paths.rs +++ b/crates/owlry/src/paths.rs @@ -61,6 +61,15 @@ pub fn config_file() -> Option { 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 { + owlry_config_dir().map(|p| p.join("owlry.lua")) +} + /// Custom user stylesheet: `$XDG_CONFIG_HOME/owlry/style.css` pub fn custom_style_file() -> Option { owlry_config_dir().map(|p| p.join("style.css")) diff --git a/crates/owlry/src/providers/application.rs b/crates/owlry/src/providers/application.rs index dd9523d..b0537c6 100644 --- a/crates/owlry/src/providers/application.rs +++ b/crates/owlry/src/providers/application.rs @@ -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] { diff --git a/crates/owlry/src/providers/command.rs b/crates/owlry/src/providers/command.rs index 3227521..0b38b25 100644 --- a/crates/owlry/src/providers/command.rs +++ b/crates/owlry/src/providers/command.rs @@ -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] { diff --git a/crates/owlry/src/providers/dmenu.rs b/crates/owlry/src/providers/dmenu.rs index 050faa0..05510c5 100644 --- a/crates/owlry/src/providers/dmenu.rs +++ b/crates/owlry/src/providers/dmenu.rs @@ -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 { diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index 3ae4b4c..41d6bdb 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -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>) -> 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>, + user_providers: Vec>, + ) -> 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 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> = 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")] diff --git a/crates/owlry/src/providers/systemd.rs b/crates/owlry/src/providers/systemd.rs index fbfb952..7fca022 100644 --- a/crates/owlry/src/providers/systemd.rs +++ b/crates/owlry/src/providers/systemd.rs @@ -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] { diff --git a/crates/owlry/src/server.rs b/crates/owlry/src/server.rs index 26c8a3c..ee427f4 100644 --- a/crates/owlry/src/server.rs +++ b/crates/owlry/src/server.rs @@ -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, 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>, frecency: Arc>, config: Arc>, + /// 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>>, + /// Filesystem watcher driving hot reload. Dropping this stops the + /// watcher thread; kept alive for the daemon's lifetime. + #[cfg(feature = "lua")] + _lua_watcher: Option, } 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> = 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 + }) + .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::>::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>, ) -> 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, diff --git a/crates/owlry/tests/ipc_test.rs b/crates/owlry/tests/ipc_test.rs index 79fc10a..ef057c5 100644 --- a/crates/owlry/tests/ipc_test.rs +++ b/crates/owlry/tests/ipc_test.rs @@ -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")); diff --git a/crates/owlry/tests/server_test.rs b/crates/owlry/tests/server_test.rs index 4870ee0..0f7b861 100644 --- a/crates/owlry/tests/server_test.rs +++ b/crates/owlry/tests/server_test.rs @@ -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); diff --git a/data/owlry.1 b/data/owlry.1 index a1a914c..336d399 100644 --- a/data/owlry.1 +++ b/data/owlry.1 @@ -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.] 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. diff --git a/data/owlry.example.lua b/data/owlry.example.lua new file mode 100644 index 0000000..fb304cf --- /dev/null +++ b/data/owlry.example.lua @@ -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 `. +-- 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, +-- }