diff --git a/Documentation.md b/Documentation.md index f88553f..6364681 100644 --- a/Documentation.md +++ b/Documentation.md @@ -48,10 +48,56 @@ This matters because the moment sibling apps and community modules depend on thi ### 1) Create a minimal config - Daemon config: `~/.config/bread/breadd.toml` (all values optional) +- Declarative rules (optional, no Lua required): `~/.config/bread/rules.toml` - Lua entry point: `~/.config/bread/init.lua` - Lua modules: `~/.config/bread/modules/` -### 2) Minimal `init.lua` +### 2) The fast path: `rules.toml` *(Since: v1.5)* + +For the common "when event X happens, do Y" case, you don't need Lua at +all. Create `~/.config/bread/rules.toml`: + +```toml +[[rule]] +on = "device.dock.connected" +run = "~/.config/bread/scripts/dock-connected.sh" + +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" + +[[rule]] +on = "device.keyboard.connected" +exec = "xset r rate 200 40" +``` + +Each `[[rule]]` needs exactly two things: an `on` (an event-name suffix — +`bread.` is implied, so `"device.dock.connected"` matches the real event +`bread.device.dock.connected`; wildcards `*`/`**`/`?` work the same way they +do in `bread.on()`) and exactly one action: + +| Action | Meaning | +|--------|---------| +| `run = ""` | Run exactly one script/program at that path. The path is tilde-expanded and quoted as a single unit for you, so spaces in it are safe — it will *not* be word-split into a command plus arguments. | +| `exec = ""` | Run a full shell command line via `bread.exec()`, exactly as if you'd typed it in a shell — quote/escape arguments yourself. | +| `notify = ""` | Show a desktop notification with this text via `bread.notify()`. | + +`rules.toml` is entirely optional and purely additive alongside +`init.lua` — both can coexist, rules load before user-defined modules, and +an absent file is not an error. A malformed rule (missing/empty `on`, or +zero/multiple action keys set) doesn't stop the rest of the file from +working: the other rules in the file still register, and the specific bad +rule shows up via `bread doctor` (see [Debugging tips](#debugging-tips)) +the same way a broken Lua module's error would. + +This covers the common cases directly. For fuzzier matching (substring +device-name matching, filtering by a list of monitors, etc.) or any logic +beyond "run this one action," reach for `bread.devices` / +`bread.monitors` or hand-written Lua in `init.lua` — see +[Dictionary: Built-in modules](#dictionary-built-in-modules) and the next +section. + +### 3) Minimal `init.lua` ```lua bread.on("bread.system.startup", function(event) @@ -60,7 +106,7 @@ bread.on("bread.system.startup", function(event) end) ``` -### 3) Start the daemon +### 4) Start the daemon ```bash systemctl --user start breadd @@ -69,7 +115,7 @@ systemctl --user start breadd breadd ``` -### 4) Check that it's running +### 5) Check that it's running ```bash bread ping @@ -678,6 +724,37 @@ Storage is scoped per module and is not shared across modules. Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config. +### `bread.rules` *(Since: v1.5)* + +The Lua side of the `rules.toml` declarative automation layer described in +[Getting started](#getting-started) — there is no separate API to call +here, it's driven entirely by `~/.config/bread/rules.toml`. Listed here (and +disable-able via `[modules].disable = ["bread.rules"]` like every other +built-in) because it's a real module the same way `bread.devices` is, just +one whose configuration lives in TOML instead of Lua. + +```toml +# ~/.config/bread/rules.toml +[[rule]] +on = "device.dock.connected" +run = "~/.config/bread/scripts/dock-connected.sh" + +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" + +[[rule]] +on = "device.keyboard.connected" +exec = "xset r rate 200 40" +``` + +Each rule's `on` becomes a `bread.on("bread." .. on, ...)` subscription — +see [Getting started](#getting-started) for the full `run`/`exec`/`notify` +semantics and validation rules. `rules.toml`'s absence is not an error; +parse/validation problems are reported the same way a broken hand-written +module's `on_load` error would be — via `bread doctor` / `modules.list`, +against the `bread.rules` module name. + ### `bread.monitors` High-level declarative monitor event handlers. diff --git a/README.md b/README.md index 944febb..ebf18b4 100644 --- a/README.md +++ b/README.md @@ -153,11 +153,33 @@ default_urgency = "normal" notify_send_path = "notify-send" [modules] -builtin = true # load built-in modules (monitors, devices, workspaces, binds) +builtin = true # load built-in modules (monitors, devices, workspaces, binds, rules) disable = [] # list of built-in module names to disable ``` -Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`: +For the common "when event X happens, do Y" case, you don't need Lua at +all — drop rules straight into `~/.config/bread/rules.toml` and skip +`init.lua` entirely: + +```toml +# ~/.config/bread/rules.toml +[[rule]] +on = "device.dock.connected" +run = "~/.config/bread/scripts/dock-connected.sh" + +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" +``` + +It's optional and purely additive alongside `init.lua` — see +[Getting started in Documentation.md](Documentation.md#getting-started) for +the full schema (`run` vs `exec` vs `notify`, wildcard `on` patterns, and +how a malformed rule surfaces via `bread doctor`). + +For anything beyond a single action per event, your automation lives in +`~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are +auto-loaded after `init.lua`: ```lua -- ~/.config/bread/init.lua diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 7cb3526..d54b639 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -282,6 +282,19 @@ fn config_path() -> PathBuf { expand_home("~/.config/bread/breadd.toml") } +/// Location of the optional `rules.toml` — declarative automation rules +/// (see `crate::core::rules`). Resolved with the exact same +/// `XDG_CONFIG_HOME`-vs-`HOME` precedence as `config_path()` above; see the +/// `expand_home` doc comment for why every config-adjacent path the daemon +/// resolves has to agree on that precedence. +pub fn rules_path() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread").join("rules.toml"); + } + + expand_home("~/.config/bread/rules.toml") +} + /// Expands a leading `~/`. `~/.config/...` paths specifically prefer /// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own /// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point` @@ -290,7 +303,7 @@ fn config_path() -> PathBuf { /// though the config file that sets them was found via that same variable, /// which is exactly the kind of inconsistency that made init.lua/module /// loading silently no-op for a XDG_CONFIG_HOME-only test setup. -fn expand_home(input: &str) -> PathBuf { +pub(crate) fn expand_home(input: &str) -> PathBuf { if let Some(stripped) = input.strip_prefix("~/.config/") { if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { return Path::new(&xdg_config).join(stripped); @@ -635,4 +648,25 @@ legacy_hyprland_event_names = false PathBuf::from("/synthetic/home/.config/bread/breadd.toml") ); } + + #[test] + fn rules_path_respects_xdg_config_home() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config"); + assert_eq!( + rules_path(), + PathBuf::from("/synthetic/xdg-config/bread/rules.toml") + ); + } + + #[test] + fn rules_path_falls_back_to_home_when_no_xdg() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::set_var("HOME", "/synthetic/home"); + assert_eq!( + rules_path(), + PathBuf::from("/synthetic/home/.config/bread/rules.toml") + ); + } } diff --git a/breadd/src/core/mod.rs b/breadd/src/core/mod.rs index bdb2e19..cffd9da 100644 --- a/breadd/src/core/mod.rs +++ b/breadd/src/core/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod normalizer; +pub mod rules; pub mod state_engine; pub mod subscriptions; pub mod supervisor; diff --git a/breadd/src/core/rules.rs b/breadd/src/core/rules.rs new file mode 100644 index 0000000..338811c --- /dev/null +++ b/breadd/src/core/rules.rs @@ -0,0 +1,410 @@ +//! `rules.toml` — a declarative shortcut for the common "when event X +//! happens, do Y" case that otherwise requires hand-written Lua +//! (`bread.on(...)`, `bread.exec(...)`, etc). +//! +//! This module owns TOML parsing and validation only; it has no `mlua` +//! dependency so it can be unit-tested in isolation. The `bread.rules` +//! built-in Lua module (`breadd/src/lua/mod.rs`, `BUILTIN_RULES`) is the +//! other half — it takes the [`ParsedRule`]s this module produces and turns +//! each one into a real `bread.on()` subscription. +//! +//! Schema: +//! +//! ```toml +//! [[rule]] +//! on = "device.dock.connected" # matched against "bread." .. on, wildcards allowed +//! run = "~/.config/bread/scripts/dock-connected.sh" +//! +//! [[rule]] +//! on = "power.ac.disconnected" +//! notify = "Unplugged" +//! +//! [[rule]] +//! on = "device.keyboard.connected" +//! exec = "xset r rate 200 40" +//! ``` +//! +//! Exactly one of `run` / `notify` / `exec` must be set per rule. `run` and +//! `exec` both ultimately shell out via `bread.exec()`, but with distinct +//! semantics documented on [`RuleAction`]. + +use std::path::Path; + +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +struct RulesFile { + #[serde(default, rename = "rule")] + rule: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct RawRule { + on: Option, + run: Option, + notify: Option, + exec: Option, +} + +/// The action a validated rule fires when its event matches. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleAction { + /// Path to a single script/executable. Treated as exactly one program — + /// tilde-expanded and shell-quoted as a whole before being handed to + /// `bread.exec()`, so a path containing spaces still runs as one file + /// rather than being word-split into a command plus arguments. + Run(String), + /// A raw shell command line, passed to `bread.exec()` verbatim (same as + /// calling `bread.exec()` from hand-written Lua) — you're responsible + /// for quoting/escaping exactly as if you'd typed it in a shell. + Exec(String), + /// A desktop notification message, passed to `bread.notify()`. + Notify(String), +} + +/// A `[[rule]]` entry that passed validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedRule { + /// Event-name suffix, e.g. `"device.dock.connected"`. The real + /// subscription is `"bread." .. on` — wildcards (`*`, `**`, `?`) are + /// whatever `bread.on()` itself supports, since this is handed straight + /// through. + pub on: String, + pub action: RuleAction, +} + +/// A `[[rule]]` entry that failed validation — reported, not silently +/// dropped, so it shows up via `bread doctor` the same way a broken Lua +/// module would. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleIssue { + /// Zero-based position of the `[[rule]]` table in the file. + pub index: usize, + /// The rule's `on` value, if it had one (helps identify *which* rule in + /// a large file when `on` itself isn't the problem). + pub on: Option, + pub message: String, +} + +impl std::fmt::Display for RuleIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.on { + Some(on) => write!(f, "rule #{} (on = \"{}\"): {}", self.index, on, self.message), + None => write!(f, "rule #{}: {}", self.index, self.message), + } + } +} + +/// Result of attempting to load `rules.toml`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RulesLoadOutcome { + /// The file doesn't exist. Not an error — `rules.toml` is entirely + /// optional and purely additive alongside `init.lua`. + Absent, + /// The file exists but couldn't be read, or doesn't parse as TOML at + /// all — no rules could be recovered from it. + Fatal(String), + /// The file parsed as TOML. `rules` are the entries that passed + /// validation (register these); `issues` describes any `[[rule]]` + /// entries that didn't (report these, but they don't block the rest of + /// the file from working). + Loaded { + rules: Vec, + issues: Vec, + }, +} + +/// Location of `rules.toml` — re-exported from `core::config` (single +/// source of truth, colocated with `config_path()`'s identical +/// `XDG_CONFIG_HOME`-vs-`HOME` resolution for `breadd.toml`) so callers that +/// only care about rules loading can reach it as `rules::rules_path()`. +pub use crate::core::config::rules_path; + +/// Reads and validates `rules.toml` at `path`. Never panics — every failure +/// mode (missing file, unreadable file, invalid TOML, invalid individual +/// rules) is represented in the returned [`RulesLoadOutcome`]. +pub fn load_rules(path: &Path) -> RulesLoadOutcome { + if !path.exists() { + return RulesLoadOutcome::Absent; + } + + let raw = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => return RulesLoadOutcome::Fatal(format!("failed to read rules.toml: {e}")), + }; + + let parsed: RulesFile = match toml::from_str(&raw) { + Ok(v) => v, + Err(e) => return RulesLoadOutcome::Fatal(format!("failed to parse rules.toml: {e}")), + }; + + let mut rules = Vec::new(); + let mut issues = Vec::new(); + for (index, raw_rule) in parsed.rule.into_iter().enumerate() { + match validate_rule(index, raw_rule) { + Ok(rule) => rules.push(rule), + Err(issue) => issues.push(issue), + } + } + + RulesLoadOutcome::Loaded { rules, issues } +} + +fn validate_rule(index: usize, raw: RawRule) -> Result { + let on = raw.on.filter(|s| !s.trim().is_empty()); + + let mut present = Vec::new(); + if raw.run.is_some() { + present.push("run"); + } + if raw.notify.is_some() { + present.push("notify"); + } + if raw.exec.is_some() { + present.push("exec"); + } + + let Some(on) = on else { + return Err(RuleIssue { + index, + on: None, + message: "missing or empty `on`".to_string(), + }); + }; + + if present.is_empty() { + return Err(RuleIssue { + index, + on: Some(on), + message: "must set exactly one of `run`, `notify`, `exec` (none set)".to_string(), + }); + } + if present.len() > 1 { + return Err(RuleIssue { + index, + on: Some(on), + message: format!( + "must set exactly one of `run`, `notify`, `exec` (found: {})", + present.join(", ") + ), + }); + } + + let action = if let Some(v) = raw.run { + RuleAction::Run(v) + } else if let Some(v) = raw.notify { + RuleAction::Notify(v) + } else { + RuleAction::Exec(raw.exec.expect("exactly one action present")) + }; + + Ok(ParsedRule { on, action }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::PathBuf; + + fn write_temp(contents: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("rules.toml"); + let mut f = std::fs::File::create(&path).expect("create rules.toml"); + f.write_all(contents.as_bytes()).expect("write rules.toml"); + (dir, path) + } + + #[test] + fn absent_file_is_not_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + assert_eq!(load_rules(&path), RulesLoadOutcome::Absent); + } + + #[test] + fn invalid_toml_is_fatal() { + let (_dir, path) = write_temp("[[rule\nbroken"); + match load_rules(&path) { + RulesLoadOutcome::Fatal(msg) => assert!(msg.contains("failed to parse")), + other => panic!("expected Fatal, got {other:?}"), + } + } + + #[test] + fn empty_file_loads_with_no_rules() { + let (_dir, path) = write_temp(""); + assert_eq!( + load_rules(&path), + RulesLoadOutcome::Loaded { + rules: vec![], + issues: vec![], + } + ); + } + + #[test] + fn well_formed_rules_all_three_action_kinds_parse() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = "device.dock.connected" +run = "~/.config/bread/scripts/dock-connected.sh" + +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" + +[[rule]] +on = "device.keyboard.connected" +exec = "xset r rate 200 40" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert!(issues.is_empty()); + assert_eq!( + rules, + vec![ + ParsedRule { + on: "device.dock.connected".to_string(), + action: RuleAction::Run( + "~/.config/bread/scripts/dock-connected.sh".to_string() + ), + }, + ParsedRule { + on: "power.ac.disconnected".to_string(), + action: RuleAction::Notify("Unplugged".to_string()), + }, + ParsedRule { + on: "device.keyboard.connected".to_string(), + action: RuleAction::Exec("xset r rate 200 40".to_string()), + }, + ] + ); + } + + #[test] + fn wildcard_on_is_passed_through_unvalidated() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = "device.*.connected" +exec = "true" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert!(issues.is_empty()); + assert_eq!(rules[0].on, "device.*.connected"); + } + + #[test] + fn missing_on_is_reported_with_index_and_no_on() { + let (_dir, path) = write_temp( + r#" +[[rule]] +exec = "true" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert!(rules.is_empty()); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].index, 0); + assert_eq!(issues[0].on, None); + assert!(issues[0].message.contains("missing or empty")); + } + + #[test] + fn empty_on_is_treated_as_missing() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = " " +exec = "true" +"#, + ); + let RulesLoadOutcome::Loaded { issues, .. } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert_eq!(issues.len(), 1); + assert!(issues[0].message.contains("missing or empty")); + } + + #[test] + fn zero_action_keys_is_reported() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = "power.ac.disconnected" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert!(rules.is_empty()); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].on.as_deref(), Some("power.ac.disconnected")); + assert!(issues[0].message.contains("none set")); + } + + #[test] + fn multiple_action_keys_is_reported() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" +exec = "true" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert!(rules.is_empty()); + assert_eq!(issues.len(), 1); + assert!(issues[0].message.contains("notify")); + assert!(issues[0].message.contains("exec")); + } + + #[test] + fn one_bad_rule_does_not_block_other_valid_rules() { + let (_dir, path) = write_temp( + r#" +[[rule]] +on = "device.dock.connected" +exec = "true" + +[[rule]] +notify = "no on here" + +[[rule]] +on = "power.ac.disconnected" +notify = "Unplugged" +"#, + ); + let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else { + panic!("expected Loaded"); + }; + assert_eq!(rules.len(), 2); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].index, 1); + } + + #[test] + fn rule_issue_display_includes_index_and_on() { + let issue = RuleIssue { + index: 2, + on: Some("power.ac.disconnected".to_string()), + message: "must set exactly one of `run`, `notify`, `exec` (none set)".to_string(), + }; + assert_eq!( + issue.to_string(), + "rule #2 (on = \"power.ac.disconnected\"): must set exactly one of `run`, `notify`, `exec` (none set)" + ); + } +} diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index fa0d3fc..6cc0ac4 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -19,6 +19,7 @@ use tokio::time::{interval_at, sleep, Instant}; use tracing::{error, info, warn}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; +use crate::core::rules::{self, RuleAction, RulesLoadOutcome}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; use crate::core::types::{ @@ -299,6 +300,7 @@ impl LuaEngine { self.install_api()?; self.load_device_rules()?; self.load_profiles()?; + self.load_rules_toml()?; self.load_init_and_modules()?; self.run_on_reload(); @@ -1345,6 +1347,70 @@ impl LuaEngine { .map_err(|e| anyhow!("profiles.lua error: {e}")) } + /// Reads and validates `rules.toml` (if present) and stashes the result + /// in Lua globals for `bread.rules`'s `on_load()` to pick up once + /// `load_init_and_modules()` loads that built-in module. See the + /// `BUILTIN_RULES` doc comment for why globals rather than generating + /// Lua source text: the rule data is dynamic (comes from a config file + /// parsed fresh each reload) while `ModuleDecl::source` is a + /// `&'static str`, so passing it as real Lua values via `mlua`'s Table + /// API sidesteps ever having to hand-escape a path or shell command + /// into a Lua string literal. + fn load_rules_toml(&self) -> Result<()> { + let path = rules::rules_path(); + match rules::load_rules(&path) { + RulesLoadOutcome::Absent => Ok(()), + RulesLoadOutcome::Fatal(msg) => { + self.lua.globals().set("__rules_fatal", msg)?; + Ok(()) + } + RulesLoadOutcome::Loaded { rules, issues } => { + let data = self.lua.create_table()?; + for (i, rule) in rules.iter().enumerate() { + let tbl = self.lua.create_table()?; + tbl.set("on", rule.on.clone())?; + match &rule.action { + // `run` names exactly one program: tilde-expand it + // the same way `bread.fs.*`/`bread.exec` callers + // would, then shell-quote the whole thing so a path + // containing spaces still runs as one file rather + // than being word-split by the `sh -c` that + // `bread.exec()` runs it through. + RuleAction::Run(path) => { + let expanded = lua_expand_path(path); + let quoted = shell_quote(&expanded.to_string_lossy()); + tbl.set("run", quoted)?; + } + // `exec` is a full shell command line — pass it + // through to `bread.exec()` verbatim, exactly like + // hand-written Lua calling `bread.exec()` directly. + RuleAction::Exec(cmd) => { + tbl.set("exec", cmd.clone())?; + } + RuleAction::Notify(message) => { + tbl.set("notify", message.clone())?; + } + } + data.set(i + 1, tbl)?; + } + self.lua.globals().set("__rules_data", data)?; + + if !issues.is_empty() { + let combined = issues + .iter() + .map(|issue| issue.to_string()) + .collect::>() + .join("; "); + self.lua + .globals() + .set("__rules_warning", format!("rules.toml: {combined}"))?; + } + + Ok(()) + } + } + } + fn load_init_and_modules(&self) -> Result<()> { self.load_lua_file(&self.entry_point, "init", false)?; @@ -2648,6 +2714,15 @@ fn lua_expand_path(path: &str) -> std::path::PathBuf { std::path::PathBuf::from(path) } +/// POSIX single-quotes `s` for safe embedding as one token in a `sh -c` +/// command line — used for `rules.toml`'s `run = "