owlry 2.1.0: Lua config layer (Phase 3) #8
Reference in New Issue
Block a user
Delete Branch "feat/lua-config"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Ships the v2 rewrite's Phase 3 — Lua-driven configuration via
~/.config/owlry/owlry.lua. Full surface perdocs/lua-api.md:owlry.set/owlry.providers/owlry.tabs/owlry.provider/owlry.theme/owlry.profiles/owlry.util.*. TOML config keepsworking as a fallback until 3.0.
What's included
owlry.luaas canonical config — defaults + Lua overlay; TOMLignored 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.owlry.util.*—shell,shell_lines,read_file,glob(with~/expansion),env,hostnamenotify-based watcher debounces editor saves,re-evaluates in a fresh
LuaContext, hot-swaps atomically. Evalfailures preserve the previous state AND surface as desktop
notifications via
notify-rustso the user knows their config isbroken without checking the journal
owlry migrate-config [--force]— deterministic TOML → Luamigration. Detects pre-v2 plugins (Rune / C-ABI) + legacy
scripts/dir and emits paste-ready
owlry.provider {}skeletons with theoriginal 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 anypre-v2 artifacts left over
Request::Querygainsprefix: Option<String>so the daemon honours UI-side narrowing (
:smoke fooactuallyrestricts to the smoke provider; was previously silent no-op)
reflect "shipped in 2.1"; new
data/owlry.example.luashippedNotable invariants
LuaContext::lua: Arc<Lua>—mlua::Functionreferences don't bumpthe Lua refcount on their own; user providers hold their own
Arc<Lua>clone so they outlive the contextLoadedConfiginconfig/mod.rsis the resolution result thatpreserves the LuaContext; daemon + hot-reload use it
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:
fix(aur): export RUSTFLAGS in PKGBUILD to pin ld.bfd— Arch'sextra/rustdefaults rustc to-fuse-ld=lld, and LLD's strictleft-to-right arg processing can't satisfy
-llua5.4becausemlua-sys+lua-src emit the
-ldirective before their-L $OUT_DIR/libsearch path. BFD does multi-pass. Setting
RUSTFLAGS=-Clink-arg=-fuse-ld=bfddirectly in
build()andcheck()is the highest-precedence placeand beats Arch's defaults.
fix(tests): add missing prefix field— Phase 3.4.5 addedprefixtoRequest::Query; missed it in 3 integration-testconstructors that the chroot's
check()runs.Two earlier commits (
a4d4f30,b3764b3) were red-herring fixes forthe chroot issue — kept in history with explanatory messages.
Test matrix
--features full--no-default-features--no-default-features --features luacargo clippyacross all threejust aur-local-test owlry(clean chroot)Live verification
End-to-end smoke verified against the maintainer's real config
(
~/.config/owlry/config.toml+ legacyplugins/hyprshutdown+scripts/test.sh):migrate-config --forcewritesowlry.luawith the precedencenotice and surfaces both legacy artifacts with paste-ready skeletons
doctorreports active source, the migrate hint, and the samelegacy artifacts
config validateexit 0 / 2 / 1 paths all verified liveTest plan
--features full--no-default-features--no-default-features --features luajust aur-local-test owlrysucceeds in a clean chrootmigrate-configagainst a real pre-v2 config dirconfig validateagainst a real lua configdoctorshows the correct source + hintssaves via desktop notification
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.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 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.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.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.