Merge feature/rules-toml (Workstream E)
This commit is contained in:
commit
3c70c7a823
7 changed files with 904 additions and 6 deletions
|
|
@ -48,10 +48,56 @@ This matters because the moment sibling apps and community modules depend on thi
|
||||||
### 1) Create a minimal config
|
### 1) Create a minimal config
|
||||||
|
|
||||||
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
|
- 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 entry point: `~/.config/bread/init.lua`
|
||||||
- Lua modules: `~/.config/bread/modules/`
|
- 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 = "<path>"` | 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 = "<command line>"` | Run a full shell command line via `bread.exec()`, exactly as if you'd typed it in a shell — quote/escape arguments yourself. |
|
||||||
|
| `notify = "<message>"` | 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
|
```lua
|
||||||
bread.on("bread.system.startup", function(event)
|
bread.on("bread.system.startup", function(event)
|
||||||
|
|
@ -60,7 +106,7 @@ bread.on("bread.system.startup", function(event)
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3) Start the daemon
|
### 4) Start the daemon
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user start breadd
|
systemctl --user start breadd
|
||||||
|
|
@ -69,7 +115,7 @@ systemctl --user start breadd
|
||||||
breadd
|
breadd
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4) Check that it's running
|
### 5) Check that it's running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bread ping
|
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.
|
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`
|
### `bread.monitors`
|
||||||
|
|
||||||
High-level declarative monitor event handlers.
|
High-level declarative monitor event handlers.
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -153,11 +153,33 @@ default_urgency = "normal"
|
||||||
notify_send_path = "notify-send"
|
notify_send_path = "notify-send"
|
||||||
|
|
||||||
[modules]
|
[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
|
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
|
```lua
|
||||||
-- ~/.config/bread/init.lua
|
-- ~/.config/bread/init.lua
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,19 @@ fn config_path() -> PathBuf {
|
||||||
expand_home("~/.config/bread/breadd.toml")
|
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
|
/// Expands a leading `~/`. `~/.config/...` paths specifically prefer
|
||||||
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
|
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
|
||||||
/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point`
|
/// 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,
|
/// 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
|
/// which is exactly the kind of inconsistency that made init.lua/module
|
||||||
/// loading silently no-op for a XDG_CONFIG_HOME-only test setup.
|
/// 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 Some(stripped) = input.strip_prefix("~/.config/") {
|
||||||
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||||
return Path::new(&xdg_config).join(stripped);
|
return Path::new(&xdg_config).join(stripped);
|
||||||
|
|
@ -635,4 +648,25 @@ legacy_hyprland_event_names = false
|
||||||
PathBuf::from("/synthetic/home/.config/bread/breadd.toml")
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod normalizer;
|
pub mod normalizer;
|
||||||
|
pub mod rules;
|
||||||
pub mod state_engine;
|
pub mod state_engine;
|
||||||
pub mod subscriptions;
|
pub mod subscriptions;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|
|
||||||
410
breadd/src/core/rules.rs
Normal file
410
breadd/src/core/rules.rs
Normal file
|
|
@ -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<RawRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct RawRule {
|
||||||
|
on: Option<String>,
|
||||||
|
run: Option<String>,
|
||||||
|
notify: Option<String>,
|
||||||
|
exec: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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<ParsedRule>,
|
||||||
|
issues: Vec<RuleIssue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ParsedRule, RuleIssue> {
|
||||||
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ use tokio::time::{interval_at, sleep, Instant};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
|
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
|
||||||
|
use crate::core::rules::{self, RuleAction, RulesLoadOutcome};
|
||||||
use crate::core::state_engine::StateHandle;
|
use crate::core::state_engine::StateHandle;
|
||||||
use crate::core::subscriptions::SubscriptionId;
|
use crate::core::subscriptions::SubscriptionId;
|
||||||
use crate::core::types::{
|
use crate::core::types::{
|
||||||
|
|
@ -299,6 +300,7 @@ impl LuaEngine {
|
||||||
self.install_api()?;
|
self.install_api()?;
|
||||||
self.load_device_rules()?;
|
self.load_device_rules()?;
|
||||||
self.load_profiles()?;
|
self.load_profiles()?;
|
||||||
|
self.load_rules_toml()?;
|
||||||
self.load_init_and_modules()?;
|
self.load_init_and_modules()?;
|
||||||
self.run_on_reload();
|
self.run_on_reload();
|
||||||
|
|
||||||
|
|
@ -1345,6 +1347,70 @@ impl LuaEngine {
|
||||||
.map_err(|e| anyhow!("profiles.lua error: {e}"))
|
.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::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
self.lua
|
||||||
|
.globals()
|
||||||
|
.set("__rules_warning", format!("rules.toml: {combined}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_init_and_modules(&self) -> Result<()> {
|
fn load_init_and_modules(&self) -> Result<()> {
|
||||||
self.load_lua_file(&self.entry_point, "init", false)?;
|
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)
|
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 = "<script path>"` action
|
||||||
|
/// (see `LuaEngine::load_rules_toml`), which is meant to name exactly one
|
||||||
|
/// program regardless of spaces in its path, unlike `exec = "..."` which is
|
||||||
|
/// a full command line handed to the shell as-is.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
fn dirs_home() -> Option<std::path::PathBuf> {
|
fn dirs_home() -> Option<std::path::PathBuf> {
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
return Some(std::path::PathBuf::from(home));
|
return Some(std::path::PathBuf::from(home));
|
||||||
|
|
@ -2936,6 +3011,68 @@ end
|
||||||
return M
|
return M
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
// `bread.rules` — the Lua half of the `rules.toml` declarative automation
|
||||||
|
// layer (see `crate::core::rules` for TOML parsing/validation). This module
|
||||||
|
// itself is static like the other built-ins; the *data* it acts on is
|
||||||
|
// dynamic (parsed from the user's rules.toml each reload), so it's threaded
|
||||||
|
// through via `__rules_data` / `__rules_fatal` / `__rules_warning` globals
|
||||||
|
// that `load_rules_toml()` sets just before `load_init_and_modules()` runs
|
||||||
|
// this module's `on_load()` — the same "stash data in a global, consume it
|
||||||
|
// from a lifecycle hook, then clear it" technique `load_profiles()` already
|
||||||
|
// uses for `__profiles_path`.
|
||||||
|
//
|
||||||
|
// A fatal problem (rules.toml unreadable or not valid TOML) is surfaced by
|
||||||
|
// calling Lua's `error()`, which `run_on_load()` propagates back to
|
||||||
|
// `load_module()` exactly like any other module's `on_load` failure — so it
|
||||||
|
// shows up in `bread doctor` / `modules.list` as `bread.rules` in
|
||||||
|
// `load_error` state with a `last_error`, the same path a broken
|
||||||
|
// hand-written Lua module's error already takes. A handful of individually
|
||||||
|
// malformed `[[rule]]` entries (bad `on`, wrong number of action keys)
|
||||||
|
// aren't fatal to the rest of the file: the valid rules register first (so
|
||||||
|
// they keep working), then the collected issue list is raised the same way
|
||||||
|
// so it's still doctor-visible rather than a silent no-op.
|
||||||
|
const BUILTIN_RULES: &str = r#"
|
||||||
|
local M = bread.module({ name = "bread.rules", version = "1.0.0" })
|
||||||
|
|
||||||
|
local function run_action(rule)
|
||||||
|
if rule.run then
|
||||||
|
bread.exec(rule.run)
|
||||||
|
elseif rule.exec then
|
||||||
|
bread.exec(rule.exec)
|
||||||
|
elseif rule.notify then
|
||||||
|
bread.notify(rule.notify)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
if __rules_fatal then
|
||||||
|
local msg = __rules_fatal
|
||||||
|
__rules_fatal = nil
|
||||||
|
__rules_data = nil
|
||||||
|
__rules_warning = nil
|
||||||
|
error(msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
local data = __rules_data
|
||||||
|
__rules_data = nil
|
||||||
|
if data then
|
||||||
|
for _, rule in ipairs(data) do
|
||||||
|
bread.on("bread." .. rule.on, function(event)
|
||||||
|
run_action(rule)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if __rules_warning then
|
||||||
|
local msg = __rules_warning
|
||||||
|
__rules_warning = nil
|
||||||
|
error(msg)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
"#;
|
||||||
|
|
||||||
fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
|
fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
|
@ -2949,6 +3086,7 @@ fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
|
||||||
BUILTIN_WORKSPACES,
|
BUILTIN_WORKSPACES,
|
||||||
),
|
),
|
||||||
("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS),
|
("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS),
|
||||||
|
("bread.rules", "1.0.0", Vec::new(), BUILTIN_RULES),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (name, version, after, source) in entries {
|
for (name, version, after, source) in entries {
|
||||||
|
|
|
||||||
|
|
@ -913,6 +913,210 @@ async fn workflow_captures_error_on_failure() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rules_toml_absent_is_a_no_op() -> Result<()> {
|
||||||
|
// No rules.toml written at all — the daemon must still start cleanly
|
||||||
|
// and `bread.rules` (always present as a built-in) must come up
|
||||||
|
// `loaded` with nothing registered, not `load_error` or `not_found`.
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let health = harness.send_request("health", json!({})).await?;
|
||||||
|
let modules = health
|
||||||
|
.get("modules")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("modules array");
|
||||||
|
let rules_mod = modules
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("bread.rules"))
|
||||||
|
.expect("bread.rules should always be registered as a built-in");
|
||||||
|
assert_eq!(
|
||||||
|
rules_mod.get("status").and_then(Value::as_str),
|
||||||
|
Some("loaded")
|
||||||
|
);
|
||||||
|
assert!(rules_mod.get("last_error").and_then(Value::as_str).is_none());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rules_toml_well_formed_all_action_kinds_work_end_to_end() -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let assets = tempfile::tempdir()?;
|
||||||
|
// Deliberately includes a space to prove `run` shell-quotes the whole
|
||||||
|
// path rather than letting `sh -c` word-split it into "dock" + "sh".
|
||||||
|
let script_path = assets.path().join("dock connected.sh");
|
||||||
|
let run_marker = assets.path().join("run-marker");
|
||||||
|
let exec_marker = assets.path().join("exec-marker");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&script_path,
|
||||||
|
format!("#!/bin/sh\ntouch '{}'\n", run_marker.display()),
|
||||||
|
)?;
|
||||||
|
let mut perms = fs::metadata(&script_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&script_path, perms)?;
|
||||||
|
|
||||||
|
let rules_toml = format!(
|
||||||
|
r#"
|
||||||
|
[[rule]]
|
||||||
|
on = "device.dock.connected"
|
||||||
|
run = "{run_path}"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
on = "power.ac.disconnected"
|
||||||
|
notify = "Unplugged"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
on = "device.keyboard.connected"
|
||||||
|
exec = "touch '{exec_path}'"
|
||||||
|
"#,
|
||||||
|
run_path = script_path.display(),
|
||||||
|
exec_path = exec_marker.display(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let harness = TestHarness::spawn_with_init_and_rules(
|
||||||
|
"bread.on('bread.system.startup', function() end)\n",
|
||||||
|
Some(&rules_toml),
|
||||||
|
)?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// rules.toml parsed and registered cleanly — no load_error.
|
||||||
|
let health = harness.send_request("health", json!({})).await?;
|
||||||
|
let modules = health
|
||||||
|
.get("modules")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("modules array");
|
||||||
|
let rules_mod = modules
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("bread.rules"))
|
||||||
|
.expect("bread.rules present");
|
||||||
|
assert_eq!(
|
||||||
|
rules_mod.get("status").and_then(Value::as_str),
|
||||||
|
Some("loaded"),
|
||||||
|
"unexpected bread.rules status: {rules_mod:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `run`: fire the matching event and expect the script to have run.
|
||||||
|
harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({"event": "bread.device.dock.connected", "data": {}}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_file(&run_marker).await?;
|
||||||
|
|
||||||
|
// `exec`: same, via a raw shell command instead of a script file.
|
||||||
|
harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({"event": "bread.device.keyboard.connected", "data": {}}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_file(&exec_marker).await?;
|
||||||
|
|
||||||
|
// `notify`: bread.notify() always emits bread.notify.sent regardless of
|
||||||
|
// whether a real notify-send binary is available, so that's the
|
||||||
|
// deterministic way to observe it fired with the right message.
|
||||||
|
harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({"event": "bread.power.ac.disconnected", "data": {}}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
sleep(Duration::from_millis(150)).await;
|
||||||
|
let replay = harness
|
||||||
|
.send_request("events.replay", json!({"since_ms": 10_000}))
|
||||||
|
.await?;
|
||||||
|
let sent = replay
|
||||||
|
.as_array()
|
||||||
|
.expect("replay array")
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.get("event").and_then(Value::as_str) == Some("bread.notify.sent"))
|
||||||
|
.expect("bread.notify.sent should have been emitted");
|
||||||
|
assert_eq!(
|
||||||
|
sent.get("data")
|
||||||
|
.and_then(|d| d.get("message"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("Unplugged")
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rules_toml_malformed_rule_surfaces_doctor_visible_error() -> Result<()> {
|
||||||
|
// Rule #0 is fine; rule #1 has no action key at all.
|
||||||
|
let rules_toml = r#"
|
||||||
|
[[rule]]
|
||||||
|
on = "device.dock.connected"
|
||||||
|
exec = "true"
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
on = "power.ac.disconnected"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let harness = TestHarness::spawn_with_init_and_rules(
|
||||||
|
"bread.on('bread.system.startup', function() end)\n",
|
||||||
|
Some(rules_toml),
|
||||||
|
)?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let health = harness.send_request("health", json!({})).await?;
|
||||||
|
let modules = health
|
||||||
|
.get("modules")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.expect("modules array");
|
||||||
|
let rules_mod = modules
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("bread.rules"))
|
||||||
|
.expect("bread.rules present");
|
||||||
|
assert_eq!(
|
||||||
|
rules_mod.get("status").and_then(Value::as_str),
|
||||||
|
Some("load_error"),
|
||||||
|
"malformed rule should surface as load_error: {rules_mod:?}"
|
||||||
|
);
|
||||||
|
let last_error = rules_mod
|
||||||
|
.get("last_error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("last_error should be set");
|
||||||
|
assert!(
|
||||||
|
last_error.contains("rule #1"),
|
||||||
|
"expected the bad rule's index in the error, got: {last_error}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
last_error.contains("must set exactly one"),
|
||||||
|
"expected the validation message, got: {last_error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The daemon itself must not have crashed — still reachable.
|
||||||
|
let ping = harness.send_request("ping", json!({})).await?;
|
||||||
|
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls for `path` to exist, or times out after 5 seconds — `bread.exec`
|
||||||
|
/// is fire-and-forget (spawn_blocking + `sh -c`), so its side effects land
|
||||||
|
/// asynchronously relative to the IPC call that triggered them.
|
||||||
|
async fn wait_for_file(path: &Path) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
Err(anyhow!(
|
||||||
|
"expected file was not created in time: {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Polls `workflows.list` until `name` is present with `expected_state`, or
|
/// Polls `workflows.list` until `name` is present with `expected_state`, or
|
||||||
/// times out after 5 seconds. Returns the matching entry.
|
/// times out after 5 seconds. Returns the matching entry.
|
||||||
async fn poll_workflow_status(
|
async fn poll_workflow_status(
|
||||||
|
|
@ -952,6 +1156,14 @@ impl TestHarness {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_with_init(init_lua: &str) -> Result<Self> {
|
fn spawn_with_init(init_lua: &str) -> Result<Self> {
|
||||||
|
Self::spawn_with_init_and_rules(init_lua, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `spawn_with_init`, but also writes `rules.toml` (when `Some`)
|
||||||
|
/// into the same synthetic `~/.config/bread` before starting the
|
||||||
|
/// daemon, and exposes the synthetic `$HOME` so tests can point a
|
||||||
|
/// `run = "..."` rule at a script file they've written under it.
|
||||||
|
fn spawn_with_init_and_rules(init_lua: &str, rules_toml: Option<&str>) -> Result<Self> {
|
||||||
let temp = tempfile::tempdir()?;
|
let temp = tempfile::tempdir()?;
|
||||||
let runtime_dir = temp.path().join("runtime");
|
let runtime_dir = temp.path().join("runtime");
|
||||||
let config_home = temp.path().join("config");
|
let config_home = temp.path().join("config");
|
||||||
|
|
@ -965,6 +1177,10 @@ impl TestHarness {
|
||||||
|
|
||||||
fs::write(bread_cfg.join("init.lua"), init_lua)?;
|
fs::write(bread_cfg.join("init.lua"), init_lua)?;
|
||||||
|
|
||||||
|
if let Some(rules_toml) = rules_toml {
|
||||||
|
fs::write(bread_cfg.join("rules.toml"), rules_toml)?;
|
||||||
|
}
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
bread_cfg.join("breadd.toml"),
|
bread_cfg.join("breadd.toml"),
|
||||||
r#"
|
r#"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue