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

Merged
vikingowl merged 21 commits from feat/lua-config into main 2026-05-13 14:21:52 +02:00
Owner

Summary

Ships the v2 rewrite's Phase 3 — Lua-driven configuration via
~/.config/owlry/owlry.lua. Full surface per docs/lua-api.md:
owlry.set / owlry.providers / owlry.tabs / owlry.provider /
owlry.theme / owlry.profiles / owlry.util.*. TOML config keeps
working as a fallback until 3.0.

What's included

  • owlry.lua as canonical config — defaults + Lua overlay; TOML
    ignored when both exist (info-logged at daemon start)
  • owlry.provider {} — user-defined providers via Lua closures.
    Static (cached) in 2.1; per-keystroke (dynamic = true) lands in 2.2.
  • Host helpers under owlry.util.*shell, shell_lines,
    read_file, glob (with ~/ expansion), env, hostname
  • Hot reloadnotify-based watcher debounces editor saves,
    re-evaluates in a fresh LuaContext, hot-swaps atomically. Eval
    failures preserve the previous state AND surface as desktop
    notifications via notify-rust so the user knows their config is
    broken without checking the journal
  • owlry migrate-config [--force] — deterministic TOML → Lua
    migration. Detects pre-v2 plugins (Rune / C-ABI) + legacy scripts/
    dir and emits paste-ready owlry.provider {} skeletons with the
    original prefix / icon preserved
  • owlry config validate — categorised report (errors exit 1,
    warnings exit 2) covering unknown set keys, unknown provider ids,
    tabs ⊄ providers, duplicate provider ids, compiled-out providers,
    unknown ids inside profiles
  • owlry doctor — surfaces active config source (Lua / TOML /
    defaults), nudges TOML users toward migrate-config, lists any
    pre-v2 artifacts left over
  • Phase 3.4.5 IPC fixRequest::Query gains prefix: Option<String>
    so the daemon honours UI-side narrowing (:smoke foo actually
    restricts to the smoke provider; was previously silent no-op)
  • Phase 3.10 docs — README, CLAUDE.md, ROADMAP, man page all
    reflect "shipped in 2.1"; new data/owlry.example.lua shipped

Notable invariants

  • LuaContext::lua: Arc<Lua>mlua::Function references don't bump
    the Lua refcount on their own; user providers hold their own
    Arc<Lua> clone so they outlive the context
  • LoadedConfig in config/mod.rs is the resolution result that
    preserves the LuaContext; daemon + hot-reload use it
  • Provider id aliases (sys/systempower, uuctlsystemd)
    honoured everywhere — validator + migrator + merger share one
    canonical alias table

Chroot saga

The chroot build hit two real issues post-feature-work:

  1. fix(aur): export RUSTFLAGS in PKGBUILD to pin ld.bfd — Arch's
    extra/rust defaults rustc to -fuse-ld=lld, and LLD's strict
    left-to-right arg processing can't satisfy -llua5.4 because
    mlua-sys+lua-src emit the -l directive before their -L $OUT_DIR/lib
    search path. BFD does multi-pass. Setting RUSTFLAGS=-Clink-arg=-fuse-ld=bfd
    directly in build() and check() is the highest-precedence place
    and beats Arch's defaults.
  2. fix(tests): add missing prefix field — Phase 3.4.5 added
    prefix to Request::Query; missed it in 3 integration-test
    constructors that the chroot's check() runs.

Two earlier commits (a4d4f30, b3764b3) were red-herring fixes for
the chroot issue — kept in history with explanatory messages.

Test matrix

Config Tests Status
--features full 359 lib + 27 integration
--no-default-features 182 lib
--no-default-features --features lua 305 lib
cargo clippy across all three 0 warnings
just aur-local-test owlry (clean chroot) builds + tests pass

Live verification

End-to-end smoke verified against the maintainer's real config
(~/.config/owlry/config.toml + legacy plugins/hyprshutdown + scripts/test.sh):

  • migrate-config --force writes owlry.lua with the precedence
    notice and surfaces both legacy artifacts with paste-ready skeletons
  • doctor reports active source, the migrate hint, and the same
    legacy artifacts
  • config validate exit 0 / 2 / 1 paths all verified live

Test plan

  • Lib tests pass with --features full
  • Lib tests pass with --no-default-features
  • Lib tests pass with --no-default-features --features lua
  • Integration tests pass
  • Clippy silent in all three configurations
  • just aur-local-test owlry succeeds in a clean chroot
  • Live migrate-config against a real pre-v2 config dir
  • Live config validate against a real lua config
  • Live doctor shows the correct source + hints
  • Hot reload on file save lands within debounce + reports broken
    saves via desktop notification
## Summary Ships the v2 rewrite's Phase 3 — Lua-driven configuration via `~/.config/owlry/owlry.lua`. Full surface per `docs/lua-api.md`: `owlry.set` / `owlry.providers` / `owlry.tabs` / `owlry.provider` / `owlry.theme` / `owlry.profiles` / `owlry.util.*`. TOML config keeps working as a fallback until 3.0. ## What's included - **`owlry.lua` as canonical config** — defaults + Lua overlay; TOML ignored when both exist (info-logged at daemon start) - **`owlry.provider {}`** — user-defined providers via Lua closures. Static (cached) in 2.1; per-keystroke (`dynamic = true`) lands in 2.2. - **Host helpers** under `owlry.util.*` — `shell`, `shell_lines`, `read_file`, `glob` (with `~/` expansion), `env`, `hostname` - **Hot reload** — `notify`-based watcher debounces editor saves, re-evaluates in a fresh `LuaContext`, hot-swaps atomically. Eval failures preserve the previous state AND surface as desktop notifications via `notify-rust` so the user knows their config is broken without checking the journal - **`owlry migrate-config [--force]`** — deterministic TOML → Lua migration. Detects pre-v2 plugins (Rune / C-ABI) + legacy `scripts/` dir and emits paste-ready `owlry.provider {}` skeletons with the original prefix / icon preserved - **`owlry config validate`** — categorised report (errors exit 1, warnings exit 2) covering unknown set keys, unknown provider ids, tabs ⊄ providers, duplicate provider ids, compiled-out providers, unknown ids inside profiles - **`owlry doctor`** — surfaces active config source (Lua / TOML / defaults), nudges TOML users toward `migrate-config`, lists any pre-v2 artifacts left over - **Phase 3.4.5 IPC fix** — `Request::Query` gains `prefix: Option<String>` so the daemon honours UI-side narrowing (`:smoke foo` actually restricts to the smoke provider; was previously silent no-op) - **Phase 3.10 docs** — README, CLAUDE.md, ROADMAP, man page all reflect "shipped in 2.1"; new `data/owlry.example.lua` shipped ## Notable invariants - `LuaContext::lua: Arc<Lua>` — `mlua::Function` references don't bump the Lua refcount on their own; user providers hold their own `Arc<Lua>` clone so they outlive the context - `LoadedConfig` in `config/mod.rs` is the resolution result that preserves the LuaContext; daemon + hot-reload use it - Provider id aliases (`sys`/`system` → `power`, `uuctl` → `systemd`) honoured everywhere — validator + migrator + merger share one canonical alias table ## Chroot saga The chroot build hit two real issues post-feature-work: 1. **`fix(aur): export RUSTFLAGS in PKGBUILD to pin ld.bfd`** — Arch's `extra/rust` defaults rustc to `-fuse-ld=lld`, and LLD's strict left-to-right arg processing can't satisfy `-llua5.4` because mlua-sys+lua-src emit the `-l` directive before their `-L $OUT_DIR/lib` search path. BFD does multi-pass. Setting `RUSTFLAGS=-Clink-arg=-fuse-ld=bfd` directly in `build()` and `check()` is the highest-precedence place and beats Arch's defaults. 2. **`fix(tests): add missing prefix field`** — Phase 3.4.5 added `prefix` to `Request::Query`; missed it in 3 integration-test constructors that the chroot's `check()` runs. Two earlier commits (`a4d4f30`, `b3764b3`) were red-herring fixes for the chroot issue — kept in history with explanatory messages. ## Test matrix | Config | Tests | Status | |---|---|---| | `--features full` | 359 lib + 27 integration | ✓ | | `--no-default-features` | 182 lib | ✓ | | `--no-default-features --features lua` | 305 lib | ✓ | | `cargo clippy` across all three | 0 warnings | ✓ | | `just aur-local-test owlry` (clean chroot) | builds + tests pass | ✓ | ## Live verification End-to-end smoke verified against the maintainer's real config (`~/.config/owlry/config.toml` + legacy `plugins/hyprshutdown` + `scripts/test.sh`): - `migrate-config --force` writes `owlry.lua` with the precedence notice and surfaces both legacy artifacts with paste-ready skeletons - `doctor` reports active source, the migrate hint, and the same legacy artifacts - `config validate` exit 0 / 2 / 1 paths all verified live ## Test plan - [x] Lib tests pass with `--features full` - [x] Lib tests pass with `--no-default-features` - [x] Lib tests pass with `--no-default-features --features lua` - [x] Integration tests pass - [x] Clippy silent in all three configurations - [x] `just aur-local-test owlry` succeeds in a clean chroot - [x] Live `migrate-config` against a real pre-v2 config dir - [x] Live `config validate` against a real lua config - [x] Live `doctor` shows the correct source + hints - [x] Hot reload on file save lands within debounce + reports broken saves via desktop notification
vikingowl added 21 commits 2026-05-13 14:17:50 +02:00
Adds the lua cargo feature and a stubbed crates/owlry/src/lua/ module
in preparation for Phase 3 (Lua config layer per docs/lua-api.md).

- mlua 0.11 (lua54, vendored, send, serialize) as an optional dep so
  AUR clean-chroot builds don't depend on system Lua.
- New `lua` feature gates the module; included in the `full` feature
  set so the AUR build ships it. Disabled by default so minimal
  `cargo install` consumers don't pay for the C compile.
- Stub submodules: runtime (LuaContext), api (owlry.* surface), error
  (LuaConfigError), util (owlry.util.*). Every function is a no-op
  placeholder for the sub-phases that will wire them.
- pub mod lua gated behind #[cfg(feature = "lua")] in lib.rs.

cargo check passes with --no-default-features, --features lua, and
--features full. 221/221 lib tests still green. Zero clippy warnings
from the new module.
Implements the three core owlry.* config-collection functions per
docs/lua-api.md §4.1-§4.3:

  owlry.set { theme = "owl", width = 900, ... }
  owlry.providers { "app", "cmd", "calc", "conv", "power" }
  owlry.tabs { "app", "cmd", "uuctl" }

Accumulator: Arc<Mutex<LuaConfig>> shared between the three registered
Lua closures and the host. LuaConfig is a strongly-typed Option-fielded
struct mirroring the spec table in §4.1.

- owlry.set merges across calls (last-write-wins per key); unknown keys
  are recorded in unknown_settings for `owlry config validate` (3.9).
- owlry.providers replaces the list on each call; v1 aliases
  (sys/system/uuctl) honoured at merge time.
- owlry.tabs replaces the tab order list verbatim.

LuaConfig::merge_into(&mut Config) overlays the captured state onto
the existing TOML-derived Config without touching unset fields. This
is the data layer; Phase 3.4 wires it into Config::load().

require("owlry") returns the same table as the global `owlry` (via a
tiny package.preload shim) so both styles in the spec quick-reference
work.

Tests: 28 new (config: 11, runtime: 17) covering happy path, multi-call
merge semantics, unknown-key tolerance, type-error propagation, alias
expansion, empty lists, file reads, and error path reporting. 249/249
lib tests green with --features full. No new clippy warnings.
Implements the user-defined provider surface per docs/lua-api.md §4.4:

  owlry.provider {
    id = "hs",
    prefix = ":hs",
    items = function() return { { name = "Lock", command = "hyprlock" } } end,
  }

Spec capture (lua/config.rs):
- New LuaProviderSpec carrying id / name / prefix / tab_label / icon /
  search_noun / priority / dynamic + the mlua::Function `items` callback.
- LuaConfig grows a `user_providers: Vec<LuaProviderSpec>` accumulator.
- is_valid_provider_id() enforces the §4.4 grammar (lowercase a-z 0-9 - _).

Registration validation (lua/api.rs):
- `id` and `items` are required; missing field → clean Lua runtime error.
- Invalid id format rejected with a precise message.
- `dynamic = true` rejected in 2.1 (per spec: "lands in 2.2"). dynamic=false
  explicit is accepted.
- Duplicate id: second wins, prior entry replaced, warning logged (§6.4).
- Multiple distinct ids accumulate in registration order.

Provider bridge (lua/provider.rs, new):
- LuaProvider impl Provider (Send + Sync, verified by static assertion).
- refresh() calls the Lua function with an empty query, parses each row,
  drops items missing required name/command (warning per drop), caches.
- Errors from the user's items() are logged and result in an empty cache
  for that refresh — daemon never crashes (§6.4).
- LaunchItem.source = ItemSource::ScriptPlugin (already reserved for
  exactly this case in v2 demolition).

Lua-state lifetime fix (lua/runtime.rs):
- LuaContext now holds `Arc<Lua>`. mlua::Function references don't bump
  the Lua refcount on their own, so providers built from a dropped context
  would panic at call time. lua_handle() exposes the Arc so LuaProvider
  can keep the state alive independently.

Tests: 18 new
- lua::config: 2 (id-validation positive/negative)
- lua::provider::tests: 9 (defaults, full spec, refresh happy/sad paths,
  item field parsing, error handling, Send+Sync compile-time check)
- lua::provider::registration_tests: 8 (accumulation, duplicate replace,
  missing-required errors, invalid-id, dynamic gate)

46/46 lua tests green. 267/267 lib tests green with --features full. No
new clippy warnings. ProviderManager wiring is Phase 3.4.
Pre-existing nits surfaced by recent clippy runs. No behavior change:

- application/command/systemd: sort_by(|a,b| a.name.to_lowercase().cmp(...))
  → sort_by_key(|i| i.name.to_lowercase()). The key form computes
  to_lowercase() once per element instead of twice per comparison.
- providers/mod.rs (6 sites): descending score sort_by(|a,b| b.1.cmp(&a.1))
  → sort_by_key(|x| std::cmp::Reverse(x.1)).
- dmenu: add impl Default for DmenuProvider delegating to ::new().

cargo clippy now silent under --features full, --features lua, and
--no-default-features. 268/268 lib tests still green.
Resolution order per docs/lua-api.md §2:
  1. $XDG_CONFIG_HOME/owlry/owlry.lua  -- defaults + Lua overlay; TOML ignored
  2. $XDG_CONFIG_HOME/owlry/config.toml -- back-compat
  3. Built-in defaults

When owlry.lua is present alongside config.toml, an info log notes that
TOML is being ignored ("Lua takes precedence"). 2.2 will upgrade this to
a warning; 3.0 removes TOML entirely.

paths.rs:
- New lua_config_file() -> $XDG_CONFIG_HOME/owlry/owlry.lua.

config/mod.rs:
- New LoadedConfig { config, lua, lua_path, user_providers } gated by
  feature = "lua". Keeps the LuaContext alive so user-provider items
  closures remain callable. Drop the context and mlua::Function refs
  inside user_providers go invalid — that was the lifetime bug 3.3
  uncovered.
- LoadedConfig::load() does the full resolution; load_or_default()
  falls back to LoadedConfig::defaults() on error.
- Config::load() now consults owlry.lua first when feature enabled,
  then falls through to the existing TOML path. The Lua context is
  dropped after merge (read-only callers don't need user providers).
- Extracted load_from_toml() and detect_and_apply_terminal() so all
  three load paths share the same terminal-detection tail.
- Manual Debug impl on LoadedConfig that doesn't try to format the
  LuaContext (mlua::Lua doesn't impl Debug).

providers/mod.rs:
- new_with_config now takes a user_providers: Vec<Box<dyn Provider>>
  parameter (callers pre-build them). Appended after the built-in set;
  info-logs the count when non-empty.

server.rs:
- Server::bind switches to LoadedConfig::load_or_default() under the
  lua feature, builds LuaProvider boxes from the captured specs using
  the context's lua_handle(), and stores the LuaContext in a new
  _lua_ctx field to keep it alive for the daemon's lifetime. Hot
  reload (Phase 3.7) will use this field.
- Non-lua build keeps the existing Config::load_or_default() path
  with an empty user_providers vec.

Tests: 7 new in config::loaded_config_tests
- defaults_apply_when_no_files_exist
- lua_path_overrides_defaults_with_set_values
- lua_providers_list_disables_unlisted_built_ins
- lua_user_provider_is_captured
- lua_provider_remains_callable_after_load (end-to-end: load → snapshot
  → build LuaProvider → refresh → assert items, with the LuaContext
  field outliving the helper scope)
- lua_syntax_error_is_surfaced
- toml_only_load_returns_defaults_when_path_missing
- toml_only_load_parses_existing_file

Live smoke test (release build, $XDG_CONFIG_HOME=tempdir, custom
owlry.lua with one user provider): daemon logs "Loaded Lua config",
"Registering 1 user-defined provider(s)", "Provider 'smoke' loaded 2
items". IPC Providers returns the smoke provider; auto-mode fuzzy
query against the user-defined items succeeds.

Known limitation surfaced during smoke (task #28): UI prefix routing
via ProviderFilter::parse_query uses a hardcoded prefix table, so
typing `:smoke foo` in the UI doesn't narrow to the user provider.
Auto-mode is unaffected. Separate task tracks the parse_query
generalisation; not in 3.4 scope.

276/276 lib tests green with --features full. 174/174 with
--no-default-features. Clippy silent in both configurations.
The smoke test in Phase 3.4 surfaced a long-standing gap: parse_query
already routes `:smoke foo` → Plugin("smoke") via the dynamic-prefix
fallback (filter.rs:319-347), but the UI never told the daemon. The
UI's filter.active_prefix was set locally yet build_modes_param only
serialised filter.enabled, so the daemon rebuilt a fresh filter with
no active_prefix and searched across every provider. Same gap also
affected hardcoded plugin prefixes (`:emoji heart` etc.) — they only
appeared to work because their items outranked others on fuzzy score.

ipc.rs:
- Request::Query gains optional `prefix: Option<String>`, default None,
  skip_serializing_if = "Option::is_none". Wire-format omits the field
  when None so existing 2.0.x clients keep deserialising on a 2.1
  daemon and vice-versa.

filter.rs:
- New test parse_query_routes_unknown_prefix_to_plugin_type_id locks
  in the existing dynamic-fallback behaviour for `:smoke item`.
- New test build_prefix_param_round_trips_through_provider_type
  proves Display + FromStr agree on the wire format.

client.rs:
- `query(text, modes, prefix)` — third arg added; 2 existing tests
  updated. Two new tests with a capturing mock_server assert the
  prefix field is serialised when set and elided when None.

backend.rs:
- QueryParams grows a `prefix: Option<String>`. New helper
  build_prefix_param(filter) -> Option<String> mirrors set_prefix's
  active_prefix to the wire. All three client.query call sites updated
  (search, search_with_tag, query_async).

server.rs:
- Query handler destructures the new prefix field and applies it via
  filter.set_prefix(ProviderType::from_str(prefix)). Unknown ids fall
  through to ProviderType::Plugin(_) per the existing FromStr impl —
  symmetric with the UI's dynamic-fallback path.

Live smoke (release build, isolated XDG + socket):
- prefix=smoke + text="item" → 3 items, all smoke (was: 100, mixed)
- prefix=smoke + text="one"  → 1 item: "smoke one item"
- prefix=smoke + text=""     → 3 smoke items, frecency-sorted
- prefix=app   + text="firefox" → 1 firefox app item (regression
  check; hardcoded prefix path still works)

7 new tests (ipc:3, client:2, filter:2). 283/283 lib tests green with
--features full. Both clippy configurations silent. Closes task #28.
Implements the theme selection and named-profile surface per
docs/lua-api.md §4.5 and §6.5:

  owlry.theme("catppuccin-mocha")          -- string form: theme name
  owlry.theme { background = "#1e1e2e",    -- table form: colour overrides
                accent = "#cba6f7",
                badge_app = "#a6e3a1" }
  owlry.profiles { dev = { "app", "cmd", "ssh" }, media = {...} }

LuaConfig (lua/config.rs):
- theme_name: Option<String>  (set by string form, last-write-wins)
- theme_colors: ThemeColors   (table form, per-key last-write-wins)
- unknown_theme_keys: Vec<String> (forward-compat for 2.2+ palette)
- profiles: Option<HashMap<String, Vec<String>>>  (replace-on-call)
- New KNOWN_THEME_KEYS const lists every recognised colour name.
- merge_into() applies theme name + colour overlay (Some-only fields)
  and replaces base profiles when Lua sets them. Omitting `owlry.profiles`
  leaves existing TOML/default profiles intact.

api.rs:
- owlry.theme dispatches on mlua::Value: Value::String → set name,
  Value::Table → read colour keys. Other types raise a clean Lua
  runtime error.
- Pre-v2 `badge_sys` alias normalises to `badge_power` (mirrors the
  serde alias on ThemeColors).
- owlry.profiles: every value must be a table of strings; non-table
  values produce a clear "list of provider ids" error message.

Tests: 19 new
- lua::config (6): theme name override, colours overlay only Some
  fields, name+colours compose, profiles replace base, empty profiles
  clears, omitted profiles preserves base.
- lua::runtime (13): string form, full colour table, name + table
  compose, per-key merge across calls, badge_sys alias, unknown
  forward-compat keys, wrong-arg-type error, multi-profile capture,
  profiles replace, non-list value error, numeric coercion docs.

Smoke test (release build, isolated XDG): `owlry config show` reports
`theme = "nord"`, the four colour overrides under [appearance.colors],
and all three named profiles. 300/300 lib tests with --features full,
no new clippy warnings.
Implements the six host helpers per docs/lua-api.md §5.1:

  owlry.util.shell(cmd)        -> string  (stdout, trim_end)
  owlry.util.shell_lines(cmd)  -> table   (split, no trailing empty)
  owlry.util.read_file(path)   -> string|nil  (nil on any I/O error)
  owlry.util.glob(pattern)     -> table   (tilde-expanded; PatternError → Lua err)
  owlry.util.env(name, def?)   -> string|nil
  owlry.util.hostname()        -> string

Cargo.toml:
- New optional dep `glob = "0.3"` gated by the `lua` feature alongside
  mlua. Pure-Rust, no system Lua/C deps.

lua/util.rs:
- `build(lua) -> mlua::Result<Table>` constructs the owlry.util sub-table
  with every helper registered. Stateless; no Arc<Mutex<LuaConfig>> needed.
- shell uses `sh -c`; stderr is logged at warn on non-zero exit but
  stdout is still returned (don't crash user configs on missing commands).
- shell_lines drops the trailing empty element so a terminating newline
  doesn't produce a phantom "" item.
- read_file extends "nil if missing" (per spec) to "nil on any I/O
  failure" — saves users wrapping every read in pcall.
- glob expands a leading `~/` via `dirs::home_dir()`. `~user/`, embedded
  `~`, and env-var refs are intentionally left as-is (predictable surface).
- hostname uses libc::gethostname directly (libc is already a hard dep).

lua/api.rs:
- install() now attaches `owlry.util = util::build(lua)?` to the parent
  owlry table.

Tests: 17 new
- util.rs (15): shell trim, interior newlines preserved, shell_lines
  split/empty cases, read_file present/missing, glob match/empty/error,
  env present/missing/default, hostname non-empty, tilde expansion
  positive/negative.
- runtime.rs (2): owlry.util surface exists with all 6 functions after
  api::install; util helpers feed into a user provider's items()
  end-to-end (hostname-as-launch-item, via LuaProvider::refresh).

Smoke (release build, isolated XDG, user provider :host_info using
hostname/shell/env): daemon loads 4 items per the user provider —
"host: cn-arch", "kernel: 7.0.5-zen1-1.1-zen", "home: /home/...",
"user: cnachtigall". 317/317 lib tests with --features full. Clippy
silent across all three feature configurations.
Implements the hot-reload pipeline per docs/lua-api.md §7: the daemon
watches the user's owlry.lua and, on save, re-evaluates in a fresh
LuaContext. On success the new state hot-swaps atomically; on failure
the previous state is preserved and the user is told what broke via
BOTH the daemon log AND a desktop notification — no need to tail the
journal to discover the config is dead.

Cargo.toml:
- New optional dep `notify = "6"` gated by the `lua` feature alongside
  mlua and glob. inotify backend on Linux; no platform-specific feature
  flags needed.

lua/watcher.rs (new):
- ConfigWatcher::spawn(lua_path, config, pm, lua_ctx) wires a
  notify::RecommendedWatcher on the parent directory (atomic-rename
  saves from vim/JetBrains/etc rebind to a new inode, so watching the
  file directly misses them) and spawns the event-pump thread.
- Debounce: 200ms after the first event, drain follow-ups, then reload
  once. Coalesces burst saves into a single re-eval.
- reload() builds a fresh state in isolation; on Err keeps the old
  Config/PM/LuaContext untouched.
- Swap order: config → ProviderManager → LuaContext. Old user-provider
  Boxes inside the dropped PM retain the old Arc<Lua> until they
  themselves drop, so the OLD Lua state survives the swap until the
  OLD PM finishes dropping. New LuaProviders in new_pm hold the new
  Arc<Lua>.
- report_reload_failure() formats the full error chain and fires a
  notify_rust notification with summary/body/icon/urgency so the user
  sees "config reload failed — /path/to/owlry.lua — <exact line>" the
  moment a save breaks the file.
- Success path also emits a low-noise notification ("config reloaded").

lua/error.rs:
- LuaConfigError::Eval and ::Read no longer embed `{source}` in their
  Display strings — error_chain() walks `.source()` already, and the
  doubled rendering produced duplicated text in notifications and
  logs. Comment explains why for the next reader.

server.rs:
- _lua_ctx changed from `Option<LuaContext>` to
  `Arc<Mutex<Option<LuaContext>>>` so the watcher thread can atomically
  replace it without taking the daemon down.
- New `_lua_watcher: Option<ConfigWatcher>` field, populated only when
  loaded.lua_path is Some. Failure to spawn the watcher logs at warn
  but doesn't fail Server::bind — the daemon stays usable even if
  inotify is exhausted or perms are wrong.
- Mutex import feature-gated so --no-default-features stays
  warning-free.

Tests: 5 new in lua::watcher::tests
- reload_swaps_in_new_config_state: change max_results in the file,
  call reload() directly, verify Config got the new value.
- reload_failure_preserves_previous_state: write broken Lua, verify
  Config is untouched.
- reload_replaces_user_providers_with_new_definitions: replace user
  provider v1 with v2, verify a search for the v1 item returns nothing
  and v2 is found (the websearch dynamic provider initially false-
  positived on substring "v1 unique" via "Search: v1 unique" — locked
  the test to the exact name "v1 unique item").
- event_touches_matches_only_watched_path: paths outside the watched
  file are ignored.
- error_chain_renders_nested_causes: confirms chain walking works.

Live smoke (release build, isolated XDG, file edits via shell):
- v1 → save v2 → daemon log "hot-reload: applied"; :test prefix now
  returns 2 v2 items.
- v2 → save broken Lua → daemon log "hot-reload: re-eval of /.../
  owlry.lua failed, keeping previous config — Lua evaluation error in
  /.../owlry.lua: syntax error: [string]:2: '}' expected (to close
  '{' at line 1) near <eof>". Notification body shows the same
  detail (file path + precise error). :test prefix still returns v2
  items.
- Invalid provider id ("BadId With Spaces"): "runtime error: owlry.
  provider: id 'BadId With Spaces' invalid — must be lowercase
  alphanumeric with `-`/`_`". Old state preserved.

322/322 lib tests with --features full. Clippy silent across full,
--features lua, and --no-default-features.
Implements the TOML → owlry.lua migrator per docs/lua-api.md §9. The
stub from 2.0 is gone; `owlry migrate-config [--force]` now reads
$XDG_CONFIG_HOME/owlry/config.toml and writes an equivalent owlry.lua.

cli.rs:
- MigrateConfig grew a `--force` / `-f` flag.

lua/migrate.rs (new, 700+ lines):
- MigrateRequest { toml_path, lua_path, force } + MigrateOutcome.
- migrate() validates paths, parses TOML via the existing
  Config::load_from_toml pipeline (so pre-v2 aliases like `system` and
  `badge_sys` normalise to v2 names through serde's #[serde(alias)]),
  then calls generate_lua() and writes the file. Refuses to overwrite
  without --force; refuses if no config.toml exists.
- generate_lua(cfg, raw_toml, src) is a pure function emitting Lua
  text section-by-section. Properties:
  * Deterministic — every list and map is alphabetically sorted, so
    running the migrator twice on the same TOML produces byte-identical
    output (locked in tests::migration_is_deterministic).
  * Minimal — values matching Config::default() are omitted. A
    config.toml with no overrides produces a Lua stub of just the
    header comment.
  * Round-trippable — the generated Lua, evaluated through LuaContext
    and merged onto a default Config, matches the source Config's
    scalars (tests::round_trip_preserves_scalars).
  * Preserves leading `#` comment block from the source TOML by
    re-emitting as `--` Lua comments under "Preserved from your
    config.toml:". Other comments are intentionally dropped (cleanly,
    without DROPPED placeholders).
- emit_table_key() bracket-quotes profile names that aren't valid Lua
  identifiers (`my-media`, `media center`) and Lua 5.4 reserved words
  (`end`, `for`, etc.), so the generated file always parses.
- lua_string() escapes \", \\, \n, \r, \t. Non-ASCII passes through
  (Lua 5.4 strings are 8-bit clean).

commands.rs:
- run_migrate_config(force: bool) lives behind `#[cfg(feature = "lua")]`
  with a clean stub on the no-lua build that tells the user how to
  rebuild. Exit codes: 0 success, 1 destination-exists or no-TOML, 2
  unrecoverable.
- Success output names both paths and bytes written, plus a one-liner
  reminder that owlry.lua now takes precedence over config.toml.

Tests: 16 new in lua::migrate::tests
- lua_string escaping (quotes, backslashes, newlines, UTF-8 passthrough).
- header comment extraction (stops at first non-`#` non-empty line).
- default config → header-only output (no spurious blocks).
- emit_set only writes modified scalars.
- providers section omitted when all 11 built-ins are enabled.
- providers section emits alphabetically and excludes disabled ids.
- theme colours emit alphabetically (accent, background, badge_app order).
- profiles emit alphabetically (dev before minimal).
- profile names with hyphens get `["my-dev"]` bracket form.
- Lua reserved word `"end"` as profile name → bracket-quoted.
- determinism: twice on same Config → identical bytes.
- refuses existing destination without --force; preserves existing file.
- overwrites with --force.
- refuses when source TOML is missing.
- pre-v2 aliases (`system`, `badge_sys`) normalise to v2 names.
- round-trip: generate_lua → eval through LuaContext → merge onto
  default Config → matches scalar fields of original.

Live smoke (release build, isolated XDG, pre-v2-flavoured TOML with
header comments, `system = false`, `badge_sys = "#f38ba8"`, hyphenated
`profiles."my-media"`):
- migrate-config writes 1044 bytes; output has the header block,
  owlry.set with the 4 modified scalars, owlry.tabs, owlry.providers
  (alphabetical, excluding power/ssh), owlry.theme with v2-named
  badge_power, and owlry.profiles with `["my-media"]` bracket-quoted.
- Re-running without --force reports "already exists. Pass --force..."
  and exits 1.
- Daemon launched against the generated file logs "Loaded Lua config"
  and `config show` reports the migrated theme/scalars/profiles.

339/339 lib tests with --features full. Clippy silent across full,
--features lua, and --no-default-features.
Implements the validator per docs/lua-api.md §8. Replaces the
TOML-only "load and pass/fail" check from 2.0 with categorised
findings: errors (exit 1), warnings (exit 2), clean (exit 0).

lua/config.rs:
- LuaConfig grows `duplicate_user_provider_ids: Vec<String>`. The dedup
  in apply_provider would otherwise erase duplicates by the time the
  snapshot is read.

lua/api.rs:
- apply_provider records the id on the duplicate list when replacing
  an existing entry. The original warn! log is unchanged.

lua/validate.rs (new):
- ValidationReport { errors, warnings } with exit_code() honouring the
  §8 codes (0/1/2).
- validate(cfg) is a pure function over a LuaConfig snapshot. Surfaces:
  * Unknown keys in owlry.set
  * Unknown colour keys in owlry.theme
  * Unknown ids in owlry.providers (built-in alias table consulted;
    user-provider ids are recognised)
  * Providers compiled out — Cargo features checked via cfg! macros;
    always-on ids (app, cmd, calc, conv, power, dmenu) skip the check
  * Unknown / not-in-providers ids in owlry.tabs (user providers
    auto-join the enabled set, so they pass when listed in tabs)
  * Duplicate provider ids
  * Unknown ids inside owlry.profiles values (per-profile label)
- canonical_provider_id() mirrors the apply_providers_list alias table
  in lua/config.rs so an id accepted by the merger is never flagged.

commands.rs:
- run_config_validate now branches: when owlry.lua exists, build the
  LoadedConfig, run validate::validate on the snapshot, print the
  report with `<n> warning(s):` / `<n> error(s):` headers. Eval
  failures print the chained mlua error (file path + line/col).
- TOML fallback path unchanged when no owlry.lua is present.
- Whole feature path is `#[cfg(feature = "lua")]`-gated.

Tests: 13 new in lua::validate::tests
- clean config emits nothing, exit 0
- unknown set key warns
- unknown theme key warns
- unknown provider id warns (and `app` passes through unflagged)
- user_provider ids in owlry.providers list are recognised
- tabs with unknown id warn
- tabs with id not in owlry.providers warn ("dropped from tab bar")
- tabs with user provider id passes (auto-joins enabled set)
- duplicate provider id warns
- profile with unknown id warns and names the profile
- pre-v2 aliases (sys, uuctl) validate cleanly
- always-compiled-in providers never trigger compile-out warning
- multi-warning report keeps every finding

Live smoke (release build, isolated XDG) for each category:
- clean → "config: OK (Lua, ...)", exit 0
- unknown set key → "owlry.set: unknown key `mystery_key` — ignored
  (forward-compat for 2.2+ keys)", exit 2
- unknown provider id → "owlry.providers: unknown id `fictional_provider`
  — not a built-in and not registered by any owlry.provider call", exit 2
- tabs not in providers → "owlry.tabs: `cmd` is not in owlry.providers
  — it will be dropped from the tab bar", exit 2
- duplicate provider id → "owlry.provider: id `hs` was registered more
  than once — the last definition wins (earlier registrations are
  dropped)", exit 2
- profile with unknown id → "owlry.profiles.dev: unknown id `phantom`",
  exit 2
- syntax error → "config: ERROR — Lua evaluation error in /.../
  owlry.lua: syntax error: [string]:5: '}' expected (to close '{' at
  line 2) near <eof>", exit 1
- 5-warning combo → all five surfaced with stable ordering, exit 2

352/352 lib tests with --features full. Clippy silent across full,
--features lua, and --no-default-features.
Documentation, example config, and one validator-test fix. Version
bump and AUR push intentionally deferred.

data/owlry.example.lua (new):
- Annotated reference config exercising every surface (set / providers
  / tabs / theme / profiles / provider / util). Active section is
  minimal and validates clean.

aur/owlry/PKGBUILD:
- Ships data/owlry.example.lua to /usr/share/doc/owlry/owlry.example.lua.
  pkgver kept at 2.0.1.

README.md:
- Config table puts owlry.lua first (preferred from 2.1), config.toml
  marked legacy/fallback with precedence note linking lua-api.md §2.
- New "Quick Start (Lua config)" section with migrate-config blurb.
- migrate-config row: [--force], deterministic. config validate row:
  exit 1 errors / exit 2 warnings.
- Roadmap section flips Lua config from "lands in 2.1/3.0" to
  "shipped in 2.1"; lists 2.2 follow-ups (dynamic providers,
  owlry.bind, util.http_get).

CLAUDE.md:
- Project shape tree expands lua/ module with per-file descriptions.
- Build section documents the `lua` cargo feature.
- CLI shape line for migrate-config: [--force]. config validate:
  exit codes 1/2 mentioned.
- New "Lua config layer (2.1+)" section covers precedence, the
  notify-based watcher, desktop-notification errors, and the
  Arc<Lua> / LoadedConfig invariants.

data/owlry.1:
- --profile mentions both owlry.profiles (lua) and [profiles.<NAME>]
  (toml).
- config validate paragraph describes the categorised report and
  lists 0/1/2 exit codes.
- migrate-config description no longer says "stub" — covers
  determinism, --force/-f, pre-v2 alias normalisation, links §9.
- FILES adds ~/.config/owlry/owlry.lua and the example .lua;
  config.toml labelled legacy.

ROADMAP.md:
- "Lua-driven configuration" reworded as shipped in 2.1; example
  uses owlry.lua and the v2 API; 2.2 follow-ups listed.

cli.rs help: migrate-config stub-era text → "TOML → owlry.lua
  (--force to overwrite)".

lua/validate.rs: loosen pre_v2_aliases_are_known to assert only
that aliases aren't flagged as unknown ids. is_clean() failed
under --no-default-features --features lua because uuctl
correctly triggered the compiled-out warning (uuctl → systemd,
systemd feature off → silently dropped at runtime).

Test matrix (all green):
- --features full                          352 lib tests
- --no-default-features                    182 lib tests
- --no-default-features --features lua     305 lib tests
Clippy silent in all three configurations.

Smoke (release build, isolated XDG):
1. config validate on the shipped owlry.example.lua → OK exit 0
2. migrate-config: TOML → owlry.lua with the precedence notice
3. Daemon loads, watcher armed; appending owlry.provider triggers
   hot-reload within the debounce window
4. :phase310 prefix routes the empty query to the new provider
5. config validate against the live file still OK
Cargo's clean-chroot resolver drops inline `features = [...]` on
optional deps under `--frozen` (the same gotcha that bit rusqlite
during v2). When mlua's `vendored` feature got stripped the chroot
build tried to link a system `liblua5.4` that doesn't exist, with
60+ undefined-symbol errors from `lua_gettop`, `lua_pcallk`, etc.

Move lua54, vendored, send, and serialize out of the inline `mlua`
dep spec and into the `lua` cargo feature row using `mlua/<feat>`
syntax. cargo's resolver propagates these reliably whether or not
the lock-file is frozen.

Verified locally with `cargo clean && cargo build --frozen --release
--features full` — full from-scratch compile of every dep including
mlua + lua-src, links cleanly in 42s. 352/352 lib tests still pass.

Cross-references the same lesson captured in memory under
"Cargo optional-dep features need explicit dep/feature wiring".
Second chroot test still failed with the same `undefined symbol:
lua_gettop` linker errors after hoisting features to the cargo
feature row in a4d4f30. mlua-sys runs its build script (the search
path `-L .../mlua-sys-*/out/lib` is in the gcc command line) but no
`liblua5.4.a` lands in OUT_DIR. The `vendored` feature isn't
propagating across the `optional = true` boundary even with the
feature-row syntax.

Reproduced on the LOCAL machine: clean `cargo build --frozen
--release --features full` succeeds with Arch's exact CFLAGS,
CXXFLAGS, LDFLAGS, LTOFLAGS. mlua-sys+lua-src+cc all work. So
something specific to the chroot's `extra/rust 1.95.0` package
(or its build environment) breaks the optional-dep feature
propagation for vendored. Couldn't pin the exact root cause
without chroot interactive access.

Pragmatic fix: drop `optional = true` from mlua, glob, and notify.
Inline `features = ["lua54", "vendored", "send", "serialize"]` on
the now-non-optional mlua dep — cargo resolves non-optional dep
features eagerly and consistently, no resolver-boundary surprises.

The `lua` cargo feature stays as a pure marker (gates the
crates/owlry/src/lua module via `#[cfg(feature = "lua")]`). Minimal
builds (`cargo install owlry --no-default-features`) still compile
mlua + glob + notify but skip the lua module — they pay maybe 20-30s
of extra compile time and a few MB of stripped-binary bloat for the
deps, but get a working build matrix that's identical in chroot.

Verified locally:
- `cargo clean && cargo build --frozen --release --features full`
  builds clean in 32s with liblua5.4.a in OUT_DIR
- 352/352 lib tests with --features full
- 182/182 lib tests with --no-default-features
- Clippy silent across both configs

Re-run `just aur-local-test owlry` to confirm the chroot builds clean.
THIRD attempt at the chroot build. The real root cause: rustc's link
arg ordering plus Arch's `extra/rust` defaulting to `-fuse-ld=lld`.

lua-src emits `cargo:rustc-link-lib=static:-bundle=lua5.4` (negative
`-bundle` = "static, don't pack into the rlib") with a separate
`cargo:rustc-link-search=native=$OUT_DIR/lib`. rustc serialises these
into the final linker command line with `-llua5.4` appearing BEFORE
`-L .../mlua-sys-*/out/lib`. Single-pass linkers (LLD) honour args
left-to-right and fail to locate the archive at the time `-l` is
processed → 60+ undefined symbols out of mlua.

GNU `ld.bfd` does multi-pass resolution and finds the archive
regardless of arg order. rustup's stable toolchain (used locally)
defaults to bfd, which is why the bug never reproduced outside the
chroot despite identical Cargo.toml / Cargo.lock / cargo / rustc
versions.

Add `.cargo/config.toml` at the workspace root pinning bfd for
x86_64-unknown-linux-gnu via `rustflags = ["-C", "link-arg=-fuse-ld=bfd"]`.
binutils (which ships bfd) is already a build-essential dep of the
chroot — no new install cost. Local builds gain identical behaviour
to chroot, which is a nice side effect.

Verified locally: clean + frozen + features full → 84s, no link errors.
Sequence to reproduce the fix:
  cargo clean
  cargo build --frozen --release --features full

Reverts the previous two attempts to fix this:
- a4d4f30 (hoist mlua features to feature row)
- b3764b3 (drop optional from mlua/glob/notify)
... were both red herrings. mlua-sys's vendored feature WAS active in
the chroot — `Compiling lua-src v550.0.0` and `Compiling mlua-sys v0.10.0`
appear in build-logs/aur-test-20260513-131232.log and the link line
contains `-L .../mlua-sys-*/out/lib`. The static archive existed; LLD
just couldn't find it through the misordered arg list.

Re-run `just aur-local-test owlry` to confirm.
8383746 added .cargo/config.toml with rustflags=link-arg=-fuse-ld=bfd
but the chroot still linked with `-fuse-ld=lld`. cargo's RUSTFLAGS
env var takes precedence over [target.X.rustflags] in any
config.toml, so Arch's `extra/rust` pkg (which apparently sets its
own RUSTFLAGS to default rustc to LLD) was overriding the project
config silently.

Set `export RUSTFLAGS="-C link-arg=-fuse-ld=bfd"` directly in the
PKGBUILD's build() and check() functions. This is the highest-
precedence place — beats Arch's defaults, beats the project config,
beats any inherited env. Confirmed via the build-log chain in
build-logs/aur-test-20260513-132012.log: the link line still
contained `-fuse-ld=lld` despite the .cargo/config.toml landing in
the tarball.

`.cargo/config.toml` is kept (commit 8383746) so the local rustup
toolchain mirrors the chroot's linker. Belt-and-braces.

Re-run `just aur-local-test owlry`. Expected link line should now
contain `-fuse-ld=bfd` and the build should complete.
ad4e538 fixed the chroot link error, surfacing the next failure:
`cargo test --features full` in check() couldn't compile integration
tests because Request::Query gained an optional `prefix` field in
3.4.5 (commit bfbce42) and I missed three constructors under
crates/owlry/tests/. Unit tests under src/ were already updated.

Locally these tests compiled before because `cargo build --features
full` doesn't run them — only `cargo test` does, and I'd been running
`--lib` only. The chroot's PKGBUILD `check()` runs the full suite.

352 lib + 27 integration tests now green with --features full.
User feedback after migrate-config: TOML migration was correct but
silently ignored the legacy `plugins/<name>/` (Rune / C-ABI) and
`scripts/` directories that v2 removed support for. The files still
exist on disk but aren't loaded — easy to miss what got broken.

lua/migrate.rs:
- New LegacyArtifact { path, kind } + LegacyKind { Plugin {..} |
  ScriptsDir { entries } } types. Plugin variant carries id, name,
  prefix, icon, entry_point pulled from the original plugin.toml so
  the user has paste-ready data for the rewrite.
- scan_legacy_artifacts(config_dir) walks `plugins/` and `scripts/`
  in alphabetical order:
  * plugins/ — recognise a subdir as a plugin ONLY if it has a
    plugin.toml. Skip dotfile dirs (.claude, .git, .cache, etc.)
    even when they accidentally contain a manifest. This avoids
    false positives like the user's `plugins/.claude/` (editor
    config) and `plugins/docs/` (spec drafts under superpowers/).
  * scripts/ — surfaced as a single ScriptsDir entry with the file
    listing so the user sees what's there at a glance.
- parse_plugin_dir does a best-effort toml parse: malformed manifests
  fall back to the directory name as id with everything else None.
- MigrateOutcome grows `legacy_artifacts: Vec<LegacyArtifact>`.
  Empty when nothing matches.

commands.rs:
- New report_legacy_artifacts() prints a heads-up block per artifact
  after the success summary. Each block includes the original
  metadata AND a ready-to-paste `owlry.provider {}` skeleton:
  * Plugin block fills in id/prefix/icon from the manifest and points
    at the Rune entry_point as "this is what you need to translate".
  * ScriptsDir block proposes a wrapper provider that iterates the
    dir via owlry.util.shell_lines.
- Closing line links to docs/lua-api.md §4.4 (user providers) and
  §5.1 (owlry.util.*).

Tests: 7 new (24/24 migrate tests green)
- scan_returns_empty_when_no_legacy_dirs
- scan_detects_plugin_with_full_manifest (id/name/prefix/icon/entry_point)
- scan_skips_directories_without_plugin_toml (was: fallback to dir name)
- scan_skips_dotfile_directories (covers `.claude` false positive)
- scan_tolerates_malformed_manifest
- scan_detects_scripts_directory_with_listing
- scan_reports_both_plugins_and_scripts_when_present
- migrate_outcome_includes_legacy_artifacts_field (end-to-end)

Verified against the real config (~/.config/owlry/ with a Rune-based
hyprshutdown plugin + a scripts/test.sh): output names both, the
hyprshutdown block surfaces its original `:hs` prefix and
system-shutdown icon, the scripts block lists test.sh and proposes a
wrapper provider. The earlier false positives (`.claude`, `docs`)
no longer appear.

359/359 lib tests green with --features full. Clippy silent across
--features full, --no-default-features, --no-default-features --features lua.
The skeleton I generated for the legacy scripts/ directory used
`:map(function(name) ... end)`, which is JS/Rust idiom — Lua tables
don't have a built-in :map method. Anyone who pasted that into
their owlry.lua would have hit a runtime error on the first refresh.

Rewrite the snippet to use a standard `ipairs` loop with
`table.insert`. Verified by piping the exact rendered snippet
(against /tmp as the script dir) through `owlry config validate`:
exit 0, "OK (Lua, ...)". The hyprshutdown plugin snippet was already
valid Lua and didn't need any change.
Trivial: pluralise 'file' based on entries.len() rather than the lazy '(s)' suffix. The current output says '1 file(s)' which reads awkwardly.
User feedback after running `owlry doctor` against a TOML-only setup:
the report said "[config] OK" with no indication that owlry.lua is the
canonical format from 2.1+ — or that a `migrate-config` command
existed. Doctor is the natural place to nudge.

commands.rs:
- New `print_config_source_info()` runs after the "OK" line on a
  successful Config::load(). Cases:
  * owlry.lua present → "source: Lua (path)". When config.toml is
    also present, an extra "note: …toml is present alongside —
    ignored." line surfaces the precedence rule.
  * owlry.lua absent, config.toml present → "source: TOML (path)" +
    a 3-line hint pointing at `owlry migrate-config` (deterministic,
    `--force` to overwrite, TOML works until 3.0).
  * Neither file → "source: built-in defaults (no user config found)".
- Also pulls in the same `lua::migrate::scan_legacy_artifacts` used by
  `migrate-config` and emits a compact "legacy: N pre-v2 artifact(s)
  detected" block listing each plugin / scripts dir with paths. Closing
  line points at migrate-config for paste-ready snippets.
- `#[cfg(feature = "lua")]` branch covers the rich output; the non-lua
  build emits a simpler note that owlry.lua is ignored without the
  feature compiled in.
- Pluralisation: "1 entry"/"2 entries" and "1 artifact"/"2 artifacts"
  (no more `(s)` stutter). Same nit also fixed in migrate-config's
  "Heads up: detected N pre-v2 artifact(s)..." line for consistency.

Verified against three real scenarios:
1. config.toml + pre-v2 plugins/hyprshutdown + scripts/test.sh:
     "OK / source: TOML / hint: migrate-config / legacy: 2 detected"
2. config.toml only, empty tempdir:
     "OK / source: TOML / hint: migrate-config" (no legacy section)
3. No user config:
     "OK / source: built-in defaults (no user config found)"

Clippy silent across --features full and --no-default-features.
vikingowl merged commit 52833c1a33 into main 2026-05-13 14:21:52 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Owlibou/owlry#8