Add rules.toml: declarative event->action automation without Lua

Adds a bread.rules built-in module plus a breadd/src/core/rules.rs
parser/validator so the common "when event X happens, do Y" case
(dock connect script, AC-disconnect notification, keyboard rate on
connect) no longer requires hand-written init.lua.

- rules.toml is optional, XDG_CONFIG_HOME-aware (mirrors breadd.toml's
  config_path() resolution), and purely additive alongside init.lua.
- Each [[rule]] needs `on` (event suffix, "bread." implied, wildcards
  supported) and exactly one of run/exec/notify. `run` names a single
  script (tilde-expanded + shell-quoted so spaces in the path can't be
  word-split); `exec` is a raw shell command line passed through as-is;
  `notify` shows a desktop notification.
- Rule data is threaded into the bread.rules Lua module via globals
  set just before it loads (same technique load_profiles() already
  uses for __profiles_path), avoiding any need to hand-escape values
  into generated Lua source text.
- Parse/validation failures surface through the existing module
  load-error path (Lua error() -> run_on_load -> set_module_status),
  so a bad rules.toml shows up via `bread doctor` exactly like a
  broken hand-written module would, without blocking other valid
  rules in the same file.
- Documentation.md gets a new Getting-started fast path plus a
  Dictionary entry; README's Configuration section gets a short
  pointer. Since: v1.5.
This commit is contained in:
Breadway 2026-08-04 22:05:26 +08:00
parent 96639516b1
commit 45b5aee117
7 changed files with 904 additions and 6 deletions

View file

@ -617,6 +617,210 @@ async fn workflow_captures_error_on_failure() -> Result<()> {
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
/// times out after 5 seconds. Returns the matching entry.
async fn poll_workflow_status(
@ -656,6 +860,14 @@ impl TestHarness {
}
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 runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
@ -669,6 +881,10 @@ impl TestHarness {
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(
bread_cfg.join("breadd.toml"),
r#"