feat(lua): owlry config validate covers the Lua surface (Phase 3.9)

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.
This commit is contained in:
2026-05-13 05:12:52 +02:00
parent c200b1447c
commit aa1a38bbcf
5 changed files with 504 additions and 3 deletions
+76 -2
View File
@@ -146,11 +146,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 +183,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();
+5 -1
View File
@@ -232,13 +232,17 @@ fn apply_provider(cfg: &mut LuaConfig, t: Table) -> mlua::Result<()> {
items_fn,
};
// Duplicate id → second wins, warning emitted (spec §6.4).
// 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);
}
+6
View File
@@ -46,6 +46,12 @@ pub struct LuaConfig {
/// later wins, earlier is dropped (warning logged).
pub user_providers: Vec<LuaProviderSpec>,
/// IDs that appeared more than once in `owlry.provider { ... }` calls.
/// Stored separately so `owlry config validate` (Phase 3.9) can surface
/// them — the dedup in [`super::api::apply_provider`] would otherwise
/// erase the evidence by the time the snapshot is read.
pub duplicate_user_provider_ids: Vec<String>,
// ─── owlry.theme(...) ─────────────────────────────────────────────────
/// Theme name from the string form `owlry.theme("nord")`. Last write wins
/// across multiple calls (string forms merge with table forms — see
+2
View File
@@ -16,6 +16,7 @@ pub mod migrate;
pub mod provider;
pub mod runtime;
pub mod util;
pub mod validate;
pub mod watcher;
pub use config::{LuaConfig, LuaProviderSpec};
@@ -23,4 +24,5 @@ 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;
+415
View File
@@ -0,0 +1,415 @@
//! `owlry config validate` for the Lua side per docs/lua-api.md §8.
//!
//! Categories (with exit codes from spec §8):
//! - **errors** (exit 1) — syntax / eval failures. Returned to the caller
//! as the `LuaConfigError` from `LoadedConfig::load_lua_path`; we don't
//! surface them through [`ValidationReport`] because there's nothing
//! else to check once eval failed.
//! - **warnings** (exit 2) — unknown keys, unknown ids, tabs ⊄ providers,
//! duplicate provider ids, providers compiled out.
//! - **clean** (exit 0) — no warnings.
//!
//! The validator is a pure function over a [`LuaConfig`] snapshot. The
//! provider-id and theme-key tables it consults are the same canonical
//! sets used by [`super::config::apply_providers_list`] and
//! [`super::config::KNOWN_THEME_KEYS`], so a key/id accepted by the
//! merger is never flagged by the validator (and vice-versa).
use super::config::{KNOWN_THEME_KEYS, LuaConfig};
/// Categorised findings from validating an `owlry.lua` snapshot.
#[derive(Debug, Default, Clone)]
pub struct ValidationReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl ValidationReport {
/// Spec §8 exit codes: 0 clean, 1 errors, 2 warnings-only.
pub fn exit_code(&self) -> i32 {
if !self.errors.is_empty() {
1
} else if !self.warnings.is_empty() {
2
} else {
0
}
}
pub fn is_clean(&self) -> bool {
self.errors.is_empty() && self.warnings.is_empty()
}
}
/// Inspect a [`LuaConfig`] snapshot and produce a [`ValidationReport`].
pub fn validate(cfg: &LuaConfig) -> ValidationReport {
let mut report = ValidationReport::default();
let user_ids: Vec<&str> = cfg.user_providers.iter().map(|s| s.id.as_str()).collect();
// owlry.set — unknown keys (recorded at eval time).
for key in &cfg.unknown_settings {
report.warnings.push(format!(
"owlry.set: unknown key `{}` — ignored (forward-compat for 2.2+ keys)",
key
));
}
// owlry.theme — unknown keys (recorded at eval time).
for key in &cfg.unknown_theme_keys {
// KNOWN_THEME_KEYS contains the pre-v2 `badge_sys` alias too; the
// tracker already filters those, so any leftover key is genuinely
// unrecognised.
if !KNOWN_THEME_KEYS.contains(&key.as_str()) {
report.warnings.push(format!(
"owlry.theme: unknown colour key `{}` — ignored",
key
));
}
}
// owlry.providers — unknown ids and compiled-out warnings.
if let Some(list) = &cfg.providers {
for id in list {
match canonical_provider_id(id) {
Some(canon) => {
if !is_compiled_in(canon) {
report.warnings.push(format!(
"owlry.providers: `{}` is not compiled into this build — \
it will be silently dropped at runtime. Rebuild owlry \
with the matching cargo feature to enable it.",
id
));
}
}
None => {
if !user_ids.contains(&id.as_str()) {
report.warnings.push(format!(
"owlry.providers: unknown id `{}` — not a built-in and not \
registered by any `owlry.provider {{ id = … }}` call",
id
));
}
}
}
}
}
// owlry.tabs — unknown ids; entries not in providers list.
if let Some(tabs) = &cfg.tabs {
let enabled_canon: Option<std::collections::HashSet<&str>> =
cfg.providers.as_ref().map(|v| {
v.iter()
.filter_map(|s| canonical_provider_id(s))
.collect()
});
for id in tabs {
let canon = canonical_provider_id(id);
match canon {
Some(c) => {
if let Some(ref set) = enabled_canon
&& !set.contains(c)
{
report.warnings.push(format!(
"owlry.tabs: `{}` is not in owlry.providers — it will \
be dropped from the tab bar",
id
));
}
}
None => {
if !user_ids.contains(&id.as_str()) {
report.warnings.push(format!(
"owlry.tabs: unknown id `{}` — not a built-in and not \
a user-defined provider",
id
));
}
}
}
}
}
// owlry.provider duplicates.
for dup in &cfg.duplicate_user_provider_ids {
report.warnings.push(format!(
"owlry.provider: id `{}` was registered more than once — the last \
definition wins (earlier registrations are dropped)",
dup
));
}
// owlry.profiles — unknown ids inside each profile's mode list.
if let Some(profiles) = &cfg.profiles {
let mut profile_names: Vec<&String> = profiles.keys().collect();
profile_names.sort();
for name in profile_names {
for id in &profiles[name] {
let known = canonical_provider_id(id).is_some()
|| user_ids.contains(&id.as_str());
if !known {
report.warnings.push(format!(
"owlry.profiles.{}: unknown id `{}` — not a built-in and \
not a user-defined provider",
name, id
));
}
}
}
}
report
}
/// Resolve a Lua-side provider id to its canonical form. Mirrors the
/// alias table in [`super::config::apply_providers_list`].
fn canonical_provider_id(id: &str) -> Option<&'static str> {
match id {
"app" | "application" | "applications" => Some("applications"),
"cmd" | "command" | "commands" => Some("commands"),
"calc" | "calculator" => Some("calculator"),
"conv" | "converter" => Some("converter"),
"power" | "sys" | "system" => Some("power"),
"systemd" | "uuctl" => Some("systemd"),
"ssh" => Some("ssh"),
"clipboard" | "clip" => Some("clipboard"),
"emoji" => Some("emoji"),
"filesearch" | "file" => Some("filesearch"),
"websearch" | "web" | "search" => Some("websearch"),
"dmenu" => Some("dmenu"),
_ => None,
}
}
/// Whether the canonical provider id is compiled into the current build.
/// Always-compiled-in ids (applications, commands, calc, conv, power,
/// dmenu) return `true` unconditionally; the rest gate on cargo features.
fn is_compiled_in(canonical: &str) -> bool {
match canonical {
"applications" | "commands" | "calculator" | "converter" | "power"
| "dmenu" => true,
"clipboard" => cfg!(feature = "clipboard"),
"emoji" => cfg!(feature = "emoji"),
"filesearch" => cfg!(feature = "filesearch"),
"ssh" => cfg!(feature = "ssh"),
"systemd" => cfg!(feature = "systemd"),
"websearch" => cfg!(feature = "websearch"),
_ => true, // unknown canonical → not our call
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ThemeColors;
use std::collections::HashMap;
fn empty_cfg() -> LuaConfig {
LuaConfig::default()
}
#[test]
fn clean_config_produces_no_findings() {
let report = validate(&empty_cfg());
assert!(report.is_clean());
assert_eq!(report.exit_code(), 0);
}
#[test]
fn unknown_set_key_becomes_warning() {
let mut cfg = empty_cfg();
cfg.unknown_settings.push("mystery_2_2_key".into());
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("mystery_2_2_key")),
"got: {:?}",
report.warnings
);
}
#[test]
fn unknown_theme_key_becomes_warning() {
let mut cfg = empty_cfg();
cfg.unknown_theme_keys.push("future_palette_token".into());
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("future_palette_token"))
);
}
#[test]
fn unknown_provider_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into(), "fictional".into()]);
let report = validate(&cfg);
assert_eq!(report.exit_code(), 2);
assert!(
report.warnings.iter().any(|w| w.contains("fictional")),
"got: {:?}",
report.warnings
);
// `app` is recognised and must NOT produce a warning.
assert!(!report.warnings.iter().any(|w| w.contains("`app`")));
}
#[test]
fn user_provider_id_in_providers_list_is_known() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["hs".into()]);
// Pretend `hs` was registered as a user provider.
cfg.user_providers.push(make_spec("hs"));
let report = validate(&cfg);
assert!(report.is_clean(), "got: {:?}", report);
}
#[test]
fn tabs_unknown_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.tabs = Some(vec!["app".into(), "ghost".into()]);
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("ghost")),
"got: {:?}",
report.warnings
);
}
#[test]
fn tabs_entry_not_in_providers_list_is_warning() {
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into()]);
cfg.tabs = Some(vec!["app".into(), "cmd".into()]); // cmd not enabled
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("cmd") && w.contains("not in owlry.providers")),
"got: {:?}",
report.warnings
);
}
#[test]
fn tabs_user_provider_outside_providers_list_is_ok() {
// User providers auto-join the enabled set, so they don't need to
// appear in owlry.providers. Listing them in tabs should be fine.
let mut cfg = empty_cfg();
cfg.providers = Some(vec!["app".into()]);
cfg.tabs = Some(vec!["app".into(), "hs".into()]);
cfg.user_providers.push(make_spec("hs"));
let report = validate(&cfg);
assert!(report.is_clean(), "got: {:?}", report);
}
#[test]
fn duplicate_provider_id_becomes_warning() {
let mut cfg = empty_cfg();
cfg.duplicate_user_provider_ids.push("hs".into());
let report = validate(&cfg);
assert!(
report.warnings.iter().any(|w| w.contains("registered more than once")),
"got: {:?}",
report.warnings
);
}
#[test]
fn profile_with_unknown_id_warns_per_id() {
let mut cfg = empty_cfg();
let mut p: HashMap<String, Vec<String>> = HashMap::new();
p.insert("dev".into(), vec!["app".into(), "phantom".into()]);
cfg.profiles = Some(p);
let report = validate(&cfg);
assert!(
report
.warnings
.iter()
.any(|w| w.contains("owlry.profiles.dev") && w.contains("phantom")),
"got: {:?}",
report.warnings
);
}
#[test]
fn pre_v2_aliases_are_known() {
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);
assert!(
report.is_clean(),
"pre-v2 aliases must validate cleanly; got: {:?}",
report.warnings
);
}
#[test]
fn compiled_out_provider_is_warning_when_features_dropped() {
// We can't easily flip features at test time, but we can verify
// that ALWAYS-compiled-in ids never produce a "not compiled in"
// warning — that locks in the alias table from regressing.
let mut cfg = empty_cfg();
cfg.providers = Some(vec![
"app".into(),
"cmd".into(),
"calc".into(),
"conv".into(),
"power".into(),
]);
let report = validate(&cfg);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("not compiled into this build")),
"always-on providers must never warn about compile-out; got: {:?}",
report.warnings
);
}
#[test]
fn report_with_multiple_findings_keeps_them_all() {
let mut cfg = empty_cfg();
cfg.unknown_settings.push("a".into());
cfg.unknown_theme_keys.push("b".into());
cfg.providers = Some(vec!["nope".into()]);
cfg.duplicate_user_provider_ids.push("hs".into());
let report = validate(&cfg);
// 4 categories → at least 4 warnings.
assert!(
report.warnings.len() >= 4,
"expected several warnings, got {}: {:?}",
report.warnings.len(),
report.warnings
);
assert_eq!(report.exit_code(), 2);
}
fn make_spec(id: &str) -> super::super::config::LuaProviderSpec {
// Build a minimal LuaProviderSpec for testing. We can't easily
// synthesize an mlua::Function without a Lua context, so we
// borrow one from a throwaway state.
use mlua::Lua;
let lua = Lua::new();
let f = lua
.create_function(|_, ()| Ok(Vec::<mlua::Table>::new()))
.unwrap();
super::super::config::LuaProviderSpec {
id: id.to_string(),
name: None,
prefix: None,
tab_label: None,
icon: None,
search_noun: None,
priority: 0,
dynamic: false,
items_fn: f,
}
}
// ThemeColors / ProfileConfig usage references just to silence
// unused-import warnings when the imports above aren't otherwise hit.
#[allow(dead_code)]
fn _refs() {
let _ = ThemeColors::default();
}
}