Final Release of Version 1.0
This commit is contained in:
parent
d44ece3649
commit
9a471f3158
34 changed files with 3129 additions and 567 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -37,4 +37,3 @@ LUA_RUNTIME.md
|
||||||
CLAUDE_SPEC.md
|
CLAUDE_SPEC.md
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.github
|
|
||||||
|
|
|
||||||
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -305,7 +305,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-cli"
|
name = "bread-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
|
|
@ -327,7 +327,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -335,7 +335,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-sync"
|
name = "bread-sync"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -351,13 +351,13 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadd"
|
name = "breadd"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
|
"bread-sync",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
|
||||||
"libc",
|
"libc",
|
||||||
"mlua",
|
"mlua",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -80,13 +80,13 @@ git clone https://github.com/Breadway/bread.git
|
||||||
cd bread
|
cd bread
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon:
|
Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/install.sh
|
bash scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or step by step:
|
Or step by step (system-wide install):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
@ -377,12 +377,14 @@ bread.once("bread.system.startup", function(event)
|
||||||
bread.profile.activate("default")
|
bread.profile.activate("default")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Subscribe with a filter predicate
|
-- Subscribe with a filter predicate. The predicate goes in an opts table.
|
||||||
bread.filter("bread.device.connected", function(event)
|
bread.filter("bread.device.connected", function(event)
|
||||||
return event.data.device == "keyboard"
|
|
||||||
end, function(event)
|
|
||||||
bread.exec("xset r rate 200 40")
|
bread.exec("xset r rate 200 40")
|
||||||
end)
|
end, {
|
||||||
|
filter = function(event)
|
||||||
|
return event.data.device == "keyboard"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
-- Emit a custom event (for cross-module communication)
|
-- Emit a custom event (for cross-module communication)
|
||||||
bread.emit("mymodule.something", { key = "value" })
|
bread.emit("mymodule.something", { key = "value" })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-cli"
|
name = "bread-cli"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ mod modules_mgmt;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bread_sync::{
|
use bread_sync::{
|
||||||
config::{bread_config_dir, SyncConfig},
|
config::{bread_config_dir, SyncConfig},
|
||||||
delegates, machine, packages,
|
delegates, machine, packages, SyncRepo,
|
||||||
SyncRepo,
|
|
||||||
};
|
};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
@ -18,7 +17,11 @@ use tokio::net::UnixStream;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")]
|
#[command(
|
||||||
|
author,
|
||||||
|
version,
|
||||||
|
about = "Bread CLI - the reactive desktop automation fabric"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
|
|
@ -234,8 +237,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
ModulesCommand::Install { source } => {
|
ModulesCommand::Install { source } => {
|
||||||
let manifest =
|
let manifest = install_module(&source, &mods_dir).await?;
|
||||||
install_module(&source, &mods_dir).await?;
|
|
||||||
println!("installed {} v{}", manifest.name, manifest.version);
|
println!("installed {} v{}", manifest.name, manifest.version);
|
||||||
try_daemon_reload(socket).await;
|
try_daemon_reload(socket).await;
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +285,10 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
.get(&m.name)
|
.get(&m.name)
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
println!(" {:20} {:10} {:10} {}", m.name, m.version, status, m.source);
|
println!(
|
||||||
|
" {:20} {:10} {:10} {}",
|
||||||
|
m.name, m.version, status, m.source
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,12 +303,14 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
||||||
for manifest in targets {
|
for manifest in targets {
|
||||||
if manifest.source.starts_with("github:") {
|
if manifest.source.starts_with("github:") {
|
||||||
let old_ver = manifest.version.clone();
|
let old_ver = manifest.version.clone();
|
||||||
let new_manifest =
|
let new_manifest = install_module(&manifest.source, &mods_dir).await?;
|
||||||
install_module(&manifest.source, &mods_dir).await?;
|
|
||||||
if new_manifest.version == old_ver {
|
if new_manifest.version == old_ver {
|
||||||
println!("{} already up to date", manifest.name);
|
println!("{} already up to date", manifest.name);
|
||||||
} else {
|
} else {
|
||||||
println!("updated {} v{} → v{}", manifest.name, old_ver, new_manifest.version);
|
println!(
|
||||||
|
"updated {} v{} → v{}",
|
||||||
|
manifest.name, old_ver, new_manifest.version
|
||||||
|
);
|
||||||
updated_any = true;
|
updated_any = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -352,9 +359,11 @@ async fn install_module(
|
||||||
modules_mgmt::InstallSource::LocalPath(path) => {
|
modules_mgmt::InstallSource::LocalPath(path) => {
|
||||||
modules_mgmt::install_from_local(&path, source, mods_dir)
|
modules_mgmt::install_from_local(&path, source, mods_dir)
|
||||||
}
|
}
|
||||||
modules_mgmt::InstallSource::GitHub { user, repo, git_ref } => {
|
modules_mgmt::InstallSource::GitHub {
|
||||||
install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await
|
user,
|
||||||
}
|
repo,
|
||||||
|
git_ref,
|
||||||
|
} => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,8 +397,7 @@ async fn install_from_github(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tarball_url =
|
let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}");
|
||||||
format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}");
|
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(&tarball_url)
|
.get(&tarball_url)
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -400,8 +408,7 @@ async fn install_from_github(
|
||||||
.context("failed to read module archive")?;
|
.context("failed to read module archive")?;
|
||||||
|
|
||||||
let tmp = tempfile::tempdir()?;
|
let tmp = tempfile::tempdir()?;
|
||||||
let mut archive =
|
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
|
||||||
tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
|
|
||||||
archive.unpack(tmp.path())?;
|
archive.unpack(tmp.path())?;
|
||||||
|
|
||||||
// GitHub extracts to a single subdirectory (e.g. "user-repo-sha/")
|
// GitHub extracts to a single subdirectory (e.g. "user-repo-sha/")
|
||||||
|
|
@ -552,7 +559,10 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
|
||||||
for manager in &config.packages.managers {
|
for manager in &config.packages.managers {
|
||||||
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
||||||
if let Err(e) = packages::snapshot(manager, &dest_file) {
|
if let Err(e) = packages::snapshot(manager, &dest_file) {
|
||||||
eprintln!("bread: warning: package snapshot for {} failed: {}", manager, e);
|
eprintln!(
|
||||||
|
"bread: warning: package snapshot for {} failed: {}",
|
||||||
|
manager, e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -631,9 +641,11 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) ->
|
||||||
run_package_installs(&packages_dir, &config.packages.managers)?;
|
run_package_installs(&packages_dir, &config.packages.managers)?;
|
||||||
} else {
|
} else {
|
||||||
// Check if packages differ
|
// Check if packages differ
|
||||||
let has_package_files = config.packages.managers.iter().any(|m| {
|
let has_package_files = config
|
||||||
packages_dir.join(format!("{m}.txt")).exists()
|
.packages
|
||||||
});
|
.managers
|
||||||
|
.iter()
|
||||||
|
.any(|m| packages_dir.join(format!("{m}.txt")).exists());
|
||||||
if has_package_files {
|
if has_package_files {
|
||||||
println!(
|
println!(
|
||||||
"note: run 'bread sync pull --install-packages' to install missing packages"
|
"note: run 'bread sync pull --install-packages' to install missing packages"
|
||||||
|
|
@ -848,8 +860,11 @@ async fn stream_events(
|
||||||
since: Option<u64>,
|
since: Option<u64>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(seconds) = since {
|
if let Some(seconds) = since {
|
||||||
let replay =
|
let replay = send_request(
|
||||||
send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 }))
|
socket,
|
||||||
|
"events.replay",
|
||||||
|
json!({ "since_ms": seconds * 1000 }),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(list) = replay.as_array() {
|
if let Some(list) = replay.as_array() {
|
||||||
for item in list {
|
for item in list {
|
||||||
|
|
@ -1039,10 +1054,7 @@ fn render_doctor(health: &Value) {
|
||||||
.get("version")
|
.get("version")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let uptime_ms = health
|
let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0);
|
||||||
.get("uptime_ms")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?");
|
let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?");
|
||||||
println!(
|
println!(
|
||||||
" daemon {} (pid {})",
|
" daemon {} (pid {})",
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,14 @@ pub fn parse_source(source: &str) -> Result<InstallSource> {
|
||||||
|
|
||||||
/// Install a module from a local directory into `modules_dir`.
|
/// Install a module from a local directory into `modules_dir`.
|
||||||
/// `source_str` is the original source string recorded in the manifest.
|
/// `source_str` is the original source string recorded in the manifest.
|
||||||
pub fn install_from_local(src: &Path, source_str: &str, modules_dir: &Path) -> Result<ModuleManifest> {
|
pub fn install_from_local(
|
||||||
|
src: &Path,
|
||||||
|
source_str: &str,
|
||||||
|
modules_dir: &Path,
|
||||||
|
) -> Result<ModuleManifest> {
|
||||||
let manifest_path = src.join("bread.module.toml");
|
let manifest_path = src.join("bread.module.toml");
|
||||||
if !manifest_path.exists() {
|
if !manifest_path.exists() {
|
||||||
bail!(
|
bail!("bread: no bread.module.toml found in {}", src.display());
|
||||||
"bread: no bread.module.toml found in {}",
|
|
||||||
src.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = fs::read_to_string(&manifest_path)
|
let raw = fs::read_to_string(&manifest_path)
|
||||||
|
|
@ -136,8 +137,8 @@ pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result<ModuleMani
|
||||||
|
|
||||||
/// Read and parse a `bread.module.toml` file.
|
/// Read and parse a `bread.module.toml` file.
|
||||||
pub fn read_manifest_file(path: &Path) -> Result<ModuleManifest> {
|
pub fn read_manifest_file(path: &Path) -> Result<ModuleManifest> {
|
||||||
let raw = fs::read_to_string(path)
|
let raw =
|
||||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
toml::from_str(&raw).context("failed to parse module manifest")
|
toml::from_str(&raw).context("failed to parse module manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,8 +168,13 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
|
||||||
if src_path.is_dir() {
|
if src_path.is_dir() {
|
||||||
copy_dir(&src_path, &dst_path)?;
|
copy_dir(&src_path, &dst_path)?;
|
||||||
} else {
|
} else {
|
||||||
fs::copy(&src_path, &dst_path)
|
fs::copy(&src_path, &dst_path).with_context(|| {
|
||||||
.with_context(|| format!("failed to copy {} to {}", src_path.display(), dst_path.display()))?;
|
format!(
|
||||||
|
"failed to copy {} to {}",
|
||||||
|
src_path.display(),
|
||||||
|
dst_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,7 @@ fn install_from_local_succeeds_with_manifest() {
|
||||||
make_module_dir(src_tmp.path(), "mymod", "1.2.3");
|
make_module_dir(src_tmp.path(), "mymod", "1.2.3");
|
||||||
let src = src_tmp.path().join("mymod");
|
let src = src_tmp.path().join("mymod");
|
||||||
|
|
||||||
let result =
|
let result = modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path());
|
||||||
modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path());
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "install failed: {:?}", result.err());
|
assert!(result.is_ok(), "install failed: {:?}", result.err());
|
||||||
let manifest = result.unwrap();
|
let manifest = result.unwrap();
|
||||||
|
|
@ -38,7 +37,11 @@ fn install_from_local_succeeds_with_manifest() {
|
||||||
|
|
||||||
// Module directory must exist in modules dir
|
// Module directory must exist in modules dir
|
||||||
assert!(modules_tmp.path().join("mymod").exists());
|
assert!(modules_tmp.path().join("mymod").exists());
|
||||||
assert!(modules_tmp.path().join("mymod").join("bread.module.toml").exists());
|
assert!(modules_tmp
|
||||||
|
.path()
|
||||||
|
.join("mymod")
|
||||||
|
.join("bread.module.toml")
|
||||||
|
.exists());
|
||||||
assert!(modules_tmp.path().join("mymod").join("init.lua").exists());
|
assert!(modules_tmp.path().join("mymod").join("init.lua").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +82,10 @@ fn remove_nonexistent_errors() {
|
||||||
let result = modules_mgmt::remove_module("ghost", modules_tmp.path());
|
let result = modules_mgmt::remove_module("ghost", modules_tmp.path());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let msg = result.unwrap_err().to_string();
|
let msg = result.unwrap_err().to_string();
|
||||||
assert!(msg.contains("ghost"), "expected error mentioning module name, got: {msg}");
|
assert!(
|
||||||
|
msg.contains("ghost"),
|
||||||
|
"expected error mentioning module name, got: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,71 @@
|
||||||
|
//! Shared types for the Bread automation fabric.
|
||||||
|
//!
|
||||||
|
//! This crate defines the canonical event types ([`RawEvent`], [`BreadEvent`])
|
||||||
|
//! and the [`AdapterSource`] enum that both the daemon (`breadd`) and CLI
|
||||||
|
//! (`bread-cli`) depend on. Keeping these types in a separate crate guarantees
|
||||||
|
//! that adapters, the state engine, IPC clients, and the Lua bindings all
|
||||||
|
//! agree on a single wire format.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Identifies which adapter produced an event.
|
||||||
|
///
|
||||||
|
/// The state engine uses this to choose a normalization strategy and the
|
||||||
|
/// IPC layer surfaces it so subscribers can filter by origin.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AdapterSource {
|
pub enum AdapterSource {
|
||||||
|
/// The Hyprland compositor IPC socket.
|
||||||
Hyprland,
|
Hyprland,
|
||||||
|
/// The Linux udev / netlink subsystem.
|
||||||
Udev,
|
Udev,
|
||||||
|
/// Power management (sysfs / UPower).
|
||||||
Power,
|
Power,
|
||||||
|
/// Network state (rtnetlink / NetworkManager).
|
||||||
Network,
|
Network,
|
||||||
|
/// Internal events synthesized by the daemon itself
|
||||||
|
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
|
||||||
System,
|
System,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An unnormalized event as emitted by an adapter.
|
||||||
|
///
|
||||||
|
/// Raw events carry the adapter's native payload verbatim. The
|
||||||
|
/// [`EventNormalizer`](../breadd/core/normalizer/struct.EventNormalizer.html)
|
||||||
|
/// in `breadd` transforms `RawEvent` into one or more [`BreadEvent`]s with
|
||||||
|
/// a semantic name and structured data.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RawEvent {
|
pub struct RawEvent {
|
||||||
|
/// Which adapter produced this event.
|
||||||
pub source: AdapterSource,
|
pub source: AdapterSource,
|
||||||
|
/// Adapter-specific event kind (e.g. `"workspace"`, `"add"`, `"battery"`).
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
/// Adapter-specific JSON payload — not stable across versions.
|
||||||
pub payload: serde_json::Value,
|
pub payload: serde_json::Value,
|
||||||
|
/// Unix epoch milliseconds when the event was observed.
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A normalized event ready for dispatch to Lua subscribers and IPC consumers.
|
||||||
|
///
|
||||||
|
/// `BreadEvent` is the public, stable contract: event names use a dotted
|
||||||
|
/// namespace (e.g. `bread.device.dock.connected`) and the `data` payload
|
||||||
|
/// follows a documented shape per event family. See `Documentation.md` for
|
||||||
|
/// the full event catalogue.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BreadEvent {
|
pub struct BreadEvent {
|
||||||
|
/// Dotted event name, e.g. `bread.workspace.changed`.
|
||||||
pub event: String,
|
pub event: String,
|
||||||
|
/// Unix epoch milliseconds when the originating signal was observed.
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
/// The adapter that produced the underlying raw event.
|
||||||
pub source: AdapterSource,
|
pub source: AdapterSource,
|
||||||
|
/// Structured event data. The shape depends on the event family.
|
||||||
pub data: serde_json::Value,
|
pub data: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BreadEvent {
|
impl BreadEvent {
|
||||||
|
/// Construct a new event with `timestamp` set to the current wall-clock.
|
||||||
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
|
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
|
||||||
Self {
|
Self {
|
||||||
event: event.into(),
|
event: event.into(),
|
||||||
|
|
@ -37,9 +76,136 @@ impl BreadEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current Unix epoch in milliseconds.
|
||||||
|
///
|
||||||
|
/// Falls back to `0` if the system clock is before the epoch, which keeps
|
||||||
|
/// callers infallible. Used for `BreadEvent::timestamp` and replay cutoffs.
|
||||||
pub fn now_unix_ms() -> u64 {
|
pub fn now_unix_ms() -> u64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as u64
|
.as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_source_serializes_as_snake_case() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::Hyprland).unwrap(),
|
||||||
|
"\"hyprland\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::Udev).unwrap(),
|
||||||
|
"\"udev\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::Power).unwrap(),
|
||||||
|
"\"power\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::Network).unwrap(),
|
||||||
|
"\"network\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::System).unwrap(),
|
||||||
|
"\"system\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_source_round_trips_through_json() {
|
||||||
|
for source in [
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
AdapterSource::Udev,
|
||||||
|
AdapterSource::Power,
|
||||||
|
AdapterSource::Network,
|
||||||
|
AdapterSource::System,
|
||||||
|
] {
|
||||||
|
let s = serde_json::to_string(&source).unwrap();
|
||||||
|
let back: AdapterSource = serde_json::from_str(&s).unwrap();
|
||||||
|
assert_eq!(source, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_source_rejects_unknown_variant() {
|
||||||
|
let result: Result<AdapterSource, _> = serde_json::from_str("\"floppy\"");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bread_event_new_sets_current_timestamp() {
|
||||||
|
let before = now_unix_ms();
|
||||||
|
let event = BreadEvent::new("bread.test", AdapterSource::System, json!({}));
|
||||||
|
let after = now_unix_ms();
|
||||||
|
|
||||||
|
assert!(event.timestamp >= before);
|
||||||
|
assert!(event.timestamp <= after);
|
||||||
|
assert_eq!(event.event, "bread.test");
|
||||||
|
assert_eq!(event.source, AdapterSource::System);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bread_event_new_accepts_owned_and_borrowed_names() {
|
||||||
|
let owned = BreadEvent::new(String::from("bread.a"), AdapterSource::System, json!(null));
|
||||||
|
let borrowed = BreadEvent::new("bread.b", AdapterSource::System, json!(null));
|
||||||
|
assert_eq!(owned.event, "bread.a");
|
||||||
|
assert_eq!(borrowed.event, "bread.b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bread_event_round_trips_through_json() {
|
||||||
|
let original = BreadEvent {
|
||||||
|
event: "bread.device.connected".to_string(),
|
||||||
|
timestamp: 1_700_000_000_000,
|
||||||
|
source: AdapterSource::Udev,
|
||||||
|
data: json!({ "id": "usb-1-1.4", "name": "Logitech" }),
|
||||||
|
};
|
||||||
|
let raw = serde_json::to_string(&original).unwrap();
|
||||||
|
let decoded: BreadEvent = serde_json::from_str(&raw).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.event, original.event);
|
||||||
|
assert_eq!(decoded.timestamp, original.timestamp);
|
||||||
|
assert_eq!(decoded.source, original.source);
|
||||||
|
assert_eq!(decoded.data, original.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_event_round_trips_through_json() {
|
||||||
|
let original = RawEvent {
|
||||||
|
source: AdapterSource::Hyprland,
|
||||||
|
kind: "workspace".to_string(),
|
||||||
|
payload: json!({ "data": "2" }),
|
||||||
|
timestamp: 42,
|
||||||
|
};
|
||||||
|
let raw = serde_json::to_string(&original).unwrap();
|
||||||
|
let decoded: RawEvent = serde_json::from_str(&raw).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.kind, original.kind);
|
||||||
|
assert_eq!(decoded.timestamp, original.timestamp);
|
||||||
|
assert_eq!(decoded.source, original.source);
|
||||||
|
assert_eq!(decoded.payload, original.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn now_unix_ms_is_monotonically_non_decreasing_across_calls() {
|
||||||
|
let a = now_unix_ms();
|
||||||
|
let b = now_unix_ms();
|
||||||
|
assert!(b >= a, "now_unix_ms went backwards: {a} -> {b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_source_is_hashable_and_eq() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
set.insert(AdapterSource::Hyprland);
|
||||||
|
set.insert(AdapterSource::Hyprland);
|
||||||
|
set.insert(AdapterSource::Udev);
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
assert!(set.contains(&AdapterSource::Hyprland));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bread-sync"
|
name = "bread-sync"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,125 @@ pub fn expand_path(path: &str) -> PathBuf {
|
||||||
}
|
}
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn sample_config() -> SyncConfig {
|
||||||
|
SyncConfig {
|
||||||
|
remote: RemoteConfig {
|
||||||
|
url: "git@github.com:user/repo.git".to_string(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
},
|
||||||
|
machine: MachineConfig {
|
||||||
|
name: "host".to_string(),
|
||||||
|
tags: vec!["mobile".to_string()],
|
||||||
|
},
|
||||||
|
packages: PackagesConfig::default(),
|
||||||
|
delegates: DelegatesConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_round_trip() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let cfg = sample_config();
|
||||||
|
cfg.save(tmp.path()).unwrap();
|
||||||
|
|
||||||
|
assert!(tmp.path().join("sync.toml").exists());
|
||||||
|
|
||||||
|
let loaded = SyncConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(loaded.remote.url, cfg.remote.url);
|
||||||
|
assert_eq!(loaded.remote.branch, cfg.remote.branch);
|
||||||
|
assert_eq!(loaded.machine.name, cfg.machine.name);
|
||||||
|
assert_eq!(loaded.machine.tags, cfg.machine.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_config_returns_helpful_error() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("sync not initialized") || msg.contains("bread sync init"),
|
||||||
|
"expected init hint, got: {msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_invalid_toml_returns_parse_error() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap();
|
||||||
|
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.to_lowercase().contains("parse"), "got: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packages_config_default_includes_all_managers() {
|
||||||
|
let cfg = PackagesConfig::default();
|
||||||
|
assert!(cfg.enabled);
|
||||||
|
assert!(cfg.managers.contains(&"pacman".to_string()));
|
||||||
|
assert!(cfg.managers.contains(&"pip".to_string()));
|
||||||
|
assert!(cfg.managers.contains(&"npm".to_string()));
|
||||||
|
assert!(cfg.managers.contains(&"cargo".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_branch_defaults_to_main_when_omitted() {
|
||||||
|
let raw = r#"
|
||||||
|
[remote]
|
||||||
|
url = "git@example.com:r.git"
|
||||||
|
|
||||||
|
[machine]
|
||||||
|
name = "host"
|
||||||
|
"#;
|
||||||
|
let cfg: SyncConfig = toml::from_str(raw).unwrap();
|
||||||
|
assert_eq!(cfg.remote.branch, "main");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delegates_default_is_empty() {
|
||||||
|
let cfg = DelegatesConfig::default();
|
||||||
|
assert!(cfg.include.is_empty());
|
||||||
|
assert!(cfg.exclude.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_repo_path_resolves_to_data_dir() {
|
||||||
|
let path = SyncConfig::local_repo_path();
|
||||||
|
// Must include the bread sync-repo segment at the end.
|
||||||
|
let suffix = path.iter().rev().take(2).collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
suffix,
|
||||||
|
vec![
|
||||||
|
std::ffi::OsStr::new("sync-repo"),
|
||||||
|
std::ffi::OsStr::new("bread")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_path_passes_through_absolute_paths() {
|
||||||
|
assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread"));
|
||||||
|
assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_path_expands_tilde_alone_to_home() {
|
||||||
|
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||||
|
if let Some(home) = home {
|
||||||
|
assert_eq!(expand_path("~"), home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_path_expands_tilde_prefix() {
|
||||||
|
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||||
|
if let Some(home) = home {
|
||||||
|
assert_eq!(expand_path("~/.config"), home.join(".config"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) ->
|
||||||
if dst.exists() {
|
if dst.exists() {
|
||||||
for entry in fs::read_dir(dst)? {
|
for entry in fs::read_dir(dst)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let rel = entry.path().strip_prefix(dst).unwrap_or(&entry.path()).to_path_buf();
|
let rel = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(dst)
|
||||||
|
.unwrap_or(&entry.path())
|
||||||
|
.to_path_buf();
|
||||||
let src_counterpart = src.join(&rel);
|
let src_counterpart = src.join(&rel);
|
||||||
if !src_counterpart.exists() {
|
if !src_counterpart.exists() {
|
||||||
let p = entry.path();
|
let p = entry.path();
|
||||||
|
|
@ -107,3 +111,137 @@ pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> {
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_copies_nested_tree() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
fs::create_dir_all(src.path().join("a/b/c")).unwrap();
|
||||||
|
fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap();
|
||||||
|
fs::write(src.path().join("root.txt"), "root").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(),
|
||||||
|
"hello"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(dst.path().join("root.txt")).unwrap(),
|
||||||
|
"root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_overwrites_existing_files() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::write(src.path().join("f"), "new").unwrap();
|
||||||
|
fs::write(dst.path().join("f"), "old").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_removes_files_no_longer_in_src() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::write(dst.path().join("orphan.txt"), "to remove").unwrap();
|
||||||
|
fs::write(src.path().join("keeper.txt"), "stay").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||||
|
|
||||||
|
assert!(!dst.path().join("orphan.txt").exists());
|
||||||
|
assert!(dst.path().join("keeper.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_removes_directories_no_longer_in_src() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(dst.path().join("ghost-dir")).unwrap();
|
||||||
|
fs::write(dst.path().join("ghost-dir/x"), "").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||||
|
assert!(!dst.path().join("ghost-dir").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_exclude_filters_by_basename_pattern() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::write(src.path().join("keep.lua"), "lua").unwrap();
|
||||||
|
fs::write(src.path().join("trash.cache"), "").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap();
|
||||||
|
assert!(dst.path().join("keep.lua").exists());
|
||||||
|
assert!(!dst.path().join("trash.cache").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_exclude_filters_nested_directory_by_name() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(src.path().join(".git/objects")).unwrap();
|
||||||
|
fs::write(src.path().join(".git/objects/abc"), "").unwrap();
|
||||||
|
fs::write(src.path().join("init.lua"), "lua").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap();
|
||||||
|
assert!(dst.path().join("init.lua").exists());
|
||||||
|
assert!(!dst.path().join(".git").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_creates_destination_if_missing() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst_parent = TempDir::new().unwrap();
|
||||||
|
let dst = dst_parent.path().join("brand-new");
|
||||||
|
fs::write(src.path().join("hi"), "hi").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), &dst, &[]).unwrap();
|
||||||
|
assert!(dst.join("hi").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_dir_empty_src_clears_dst() {
|
||||||
|
let src = TempDir::new().unwrap();
|
||||||
|
let dst = TempDir::new().unwrap();
|
||||||
|
fs::write(dst.path().join("a"), "").unwrap();
|
||||||
|
fs::write(dst.path().join("b"), "").unwrap();
|
||||||
|
|
||||||
|
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||||
|
let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect();
|
||||||
|
assert!(remaining.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolve_include_paths ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_include_paths_uses_basename_as_key() {
|
||||||
|
let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()];
|
||||||
|
let resolved = resolve_include_paths(&includes);
|
||||||
|
assert_eq!(resolved.len(), 2);
|
||||||
|
assert_eq!(resolved[0].0, "bar");
|
||||||
|
assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar"));
|
||||||
|
assert_eq!(resolved[1].0, "quux");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_include_paths_expands_tilde_in_source() {
|
||||||
|
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||||
|
if let Some(home) = home {
|
||||||
|
let resolved = resolve_include_paths(&["~/Documents".to_string()]);
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].1, home.join("Documents"));
|
||||||
|
assert_eq!(resolved[0].0, "Documents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,9 +201,7 @@ impl SyncRepo {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for entry in statuses.iter() {
|
for entry in statuses.iter() {
|
||||||
let s = entry.status();
|
let s = entry.status();
|
||||||
let ch = if s.contains(git2::Status::INDEX_NEW)
|
let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) {
|
||||||
|| s.contains(git2::Status::WT_NEW)
|
|
||||||
{
|
|
||||||
'A'
|
'A'
|
||||||
} else if s.contains(git2::Status::INDEX_DELETED)
|
} else if s.contains(git2::Status::INDEX_DELETED)
|
||||||
|| s.contains(git2::Status::WT_DELETED)
|
|| s.contains(git2::Status::WT_DELETED)
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,91 @@ pub fn hostname() -> String {
|
||||||
.or_else(|_| std::env::var("HOST"))
|
.or_else(|_| std::env::var("HOST"))
|
||||||
.unwrap_or_else(|_| "unknown".to_string())
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_creates_machines_dir_if_missing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let machines = tmp.path().join("does/not/exist/yet");
|
||||||
|
let profile = MachineProfile::new("host".to_string(), vec![]);
|
||||||
|
profile.write(&machines).unwrap();
|
||||||
|
assert!(machines.join("host.toml").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_overwrites_existing_profile() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]);
|
||||||
|
p1.write(tmp.path()).unwrap();
|
||||||
|
|
||||||
|
let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]);
|
||||||
|
p2.write(tmp.path()).unwrap();
|
||||||
|
|
||||||
|
let loaded = MachineProfile::read(tmp.path(), "host").unwrap();
|
||||||
|
assert_eq!(loaded.tags, vec!["b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_returns_empty_when_dir_missing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let missing = tmp.path().join("nope");
|
||||||
|
assert!(MachineProfile::list(&missing).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_returns_sorted_profiles_only_for_toml_files() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
MachineProfile::new("zebra".to_string(), vec![])
|
||||||
|
.write(tmp.path())
|
||||||
|
.unwrap();
|
||||||
|
MachineProfile::new("alpha".to_string(), vec![])
|
||||||
|
.write(tmp.path())
|
||||||
|
.unwrap();
|
||||||
|
MachineProfile::new("middle".to_string(), vec![])
|
||||||
|
.write(tmp.path())
|
||||||
|
.unwrap();
|
||||||
|
// Non-toml file should be ignored.
|
||||||
|
std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap();
|
||||||
|
|
||||||
|
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||||
|
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_skips_invalid_toml_files_without_failing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
MachineProfile::new("valid".to_string(), vec![])
|
||||||
|
.write(tmp.path())
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap();
|
||||||
|
|
||||||
|
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0].name, "valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_returns_helpful_error_when_missing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("failed to read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_assigns_current_hostname_and_timestamp() {
|
||||||
|
let p = MachineProfile::new("h".to_string(), vec![]);
|
||||||
|
assert!(!p.hostname.is_empty());
|
||||||
|
assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_returns_non_empty_string() {
|
||||||
|
// Whether libc or env fallback fires, the result must be non-empty.
|
||||||
|
assert!(!hostname().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,7 @@ pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(content) = content else {
|
let Some(content) = content else {
|
||||||
eprintln!(
|
eprintln!("bread: package manager '{}' not found, skipping", manager);
|
||||||
"bread: package manager '{}' not found, skipping",
|
|
||||||
manager
|
|
||||||
);
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,18 +83,15 @@ pub fn parse_cargo(content: &str) -> Vec<String> {
|
||||||
content
|
content
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
|
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
|
||||||
.map(|l| {
|
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
|
||||||
l.split_whitespace()
|
|
||||||
.next()
|
|
||||||
.unwrap_or(l)
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_pacman() -> Result<Option<String>> {
|
fn run_pacman() -> Result<Option<String>> {
|
||||||
match Command::new("pacman").arg("-Qe").output() {
|
match Command::new("pacman").arg("-Qe").output() {
|
||||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
Ok(out) if out.status.success() => {
|
||||||
|
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||||
|
}
|
||||||
Ok(_) => Ok(None),
|
Ok(_) => Ok(None),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
|
|
@ -127,7 +121,9 @@ fn run_npm() -> Result<Option<String>> {
|
||||||
.args(["list", "-g", "--depth=0", "--parseable"])
|
.args(["list", "-g", "--depth=0", "--parseable"])
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
Ok(out) if out.status.success() => {
|
||||||
|
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||||
|
}
|
||||||
Ok(_) => Ok(None),
|
Ok(_) => Ok(None),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
|
|
@ -136,9 +132,114 @@ fn run_npm() -> Result<Option<String>> {
|
||||||
|
|
||||||
fn run_cargo() -> Result<Option<String>> {
|
fn run_cargo() -> Result<Option<String>> {
|
||||||
match Command::new("cargo").args(["install", "--list"]).output() {
|
match Command::new("cargo").args(["install", "--list"]).output() {
|
||||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
Ok(out) if out.status.success() => {
|
||||||
|
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||||
|
}
|
||||||
Ok(_) => Ok(None),
|
Ok(_) => Ok(None),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ─── parse_pacman ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacman_parses_each_line_to_first_field() {
|
||||||
|
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
|
||||||
|
assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacman_skips_blank_lines() {
|
||||||
|
let input = "firefox 1\n\n \ncurl 2\n";
|
||||||
|
assert_eq!(parse_pacman(input), vec!["firefox", "curl"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacman_handles_empty_input() {
|
||||||
|
assert!(parse_pacman("").is_empty());
|
||||||
|
assert!(parse_pacman("\n\n\n").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacman_handles_single_token_lines() {
|
||||||
|
// A line with no version still yields the package name.
|
||||||
|
assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parse_pip ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pip_strips_eq_and_ge_specifiers() {
|
||||||
|
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
|
||||||
|
assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pip_skips_comments_and_blank_lines() {
|
||||||
|
let input = "# editable install\n\nflake8==1.0\n# trailing\n";
|
||||||
|
assert_eq!(parse_pip(input), vec!["flake8"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pip_handles_package_without_specifier() {
|
||||||
|
assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parse_npm ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_extracts_basename_from_paths() {
|
||||||
|
let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n";
|
||||||
|
let pkgs = parse_npm(input);
|
||||||
|
assert!(pkgs.contains(&"npm".to_string()));
|
||||||
|
assert!(pkgs.contains(&"typescript".to_string()));
|
||||||
|
assert!(pkgs.contains(&"yarn".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_skips_root_node_modules_entry() {
|
||||||
|
let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n";
|
||||||
|
assert_eq!(parse_npm(input), vec!["typescript"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_handles_empty_input() {
|
||||||
|
assert!(parse_npm("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parse_cargo ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_extracts_crate_names_from_install_list_output() {
|
||||||
|
let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n";
|
||||||
|
assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_skips_binary_lines() {
|
||||||
|
// Indented lines are binaries inside a crate.
|
||||||
|
let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n";
|
||||||
|
assert_eq!(parse_cargo(input), vec!["alpha", "beta"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_handles_empty_input() {
|
||||||
|
assert!(parse_cargo("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── snapshot dispatch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_unknown_manager_returns_false_without_writing() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let dest = tmp.path().join("out.txt");
|
||||||
|
let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap();
|
||||||
|
assert!(!wrote);
|
||||||
|
assert!(!dest.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,8 @@ fn sync_push_creates_correct_directory_structure() {
|
||||||
|
|
||||||
// Init local sync repo
|
// Init local sync repo
|
||||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Snapshot bread dir
|
// Snapshot bread dir
|
||||||
let bread_dest = repo_tmp.path().join("bread");
|
let bread_dest = repo_tmp.path().join("bread");
|
||||||
|
|
@ -102,7 +103,11 @@ fn sync_push_creates_correct_directory_structure() {
|
||||||
// Verify structure in local repo
|
// Verify structure in local repo
|
||||||
assert!(repo_tmp.path().join("bread").exists());
|
assert!(repo_tmp.path().join("bread").exists());
|
||||||
assert!(repo_tmp.path().join("bread").join("init.lua").exists());
|
assert!(repo_tmp.path().join("bread").join("init.lua").exists());
|
||||||
assert!(repo_tmp.path().join("machines").join("testbox.toml").exists());
|
assert!(repo_tmp
|
||||||
|
.path()
|
||||||
|
.join("machines")
|
||||||
|
.join("testbox.toml")
|
||||||
|
.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -123,7 +128,8 @@ fn sync_push_snapshots_bread_config() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let bread_dest = repo_tmp.path().join("bread");
|
let bread_dest = repo_tmp.path().join("bread");
|
||||||
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
|
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
|
||||||
|
|
@ -149,7 +155,8 @@ fn sync_pull_copies_files_from_repo() {
|
||||||
|
|
||||||
// Create a local repo, add some files, push to bare
|
// Create a local repo, add some files, push to bare
|
||||||
let repo = SyncRepo::init(local_tmp.path()).unwrap();
|
let repo = SyncRepo::init(local_tmp.path()).unwrap();
|
||||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let bread_dest = local_tmp.path().join("bread");
|
let bread_dest = local_tmp.path().join("bread");
|
||||||
fs::create_dir_all(&bread_dest).unwrap();
|
fs::create_dir_all(&bread_dest).unwrap();
|
||||||
|
|
@ -160,7 +167,8 @@ fn sync_pull_copies_files_from_repo() {
|
||||||
|
|
||||||
// Now clone the bare repo and pull
|
// Now clone the bare repo and pull
|
||||||
let clone_tmp = TempDir::new().unwrap();
|
let clone_tmp = TempDir::new().unwrap();
|
||||||
let cloned = SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
|
let _cloned =
|
||||||
|
SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
|
||||||
|
|
||||||
// Apply bread/ to apply_tmp
|
// Apply bread/ to apply_tmp
|
||||||
let src = clone_tmp.path().join("bread");
|
let src = clone_tmp.path().join("bread");
|
||||||
|
|
@ -255,3 +263,220 @@ fn push_with_no_changes_returns_none() {
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── git.rs additional coverage ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_creates_repo_with_main_branch() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||||
|
fs::write(tmp.path().join("x"), "").unwrap();
|
||||||
|
repo.stage_all().unwrap();
|
||||||
|
let oid = repo.commit("initial").unwrap();
|
||||||
|
assert!(oid.is_some(), "first commit should succeed");
|
||||||
|
|
||||||
|
// Verify HEAD is on refs/heads/main.
|
||||||
|
let head_ref = std::process::Command::new("git")
|
||||||
|
.args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let head_name = String::from_utf8_lossy(&head_ref.stdout);
|
||||||
|
assert!(
|
||||||
|
head_name.trim() == "refs/heads/main",
|
||||||
|
"expected refs/heads/main, got {head_name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_or_clone_opens_existing_repo() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
SyncRepo::init(tmp.path()).unwrap();
|
||||||
|
|
||||||
|
// Calling open_or_clone on an existing path must not attempt to clone.
|
||||||
|
let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path());
|
||||||
|
assert!(again.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_or_clone_clones_into_missing_path() {
|
||||||
|
let bare = TempDir::new().unwrap();
|
||||||
|
let bare_repo = make_bare_repo(bare.path());
|
||||||
|
// Seed the bare repo with at least one commit so a clone is meaningful.
|
||||||
|
let local = TempDir::new().unwrap();
|
||||||
|
let repo = SyncRepo::init(local.path()).unwrap();
|
||||||
|
fs::write(local.path().join("seed"), "x").unwrap();
|
||||||
|
repo.commit("seed").unwrap();
|
||||||
|
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
repo.push("origin", "main").unwrap();
|
||||||
|
drop(bare_repo);
|
||||||
|
|
||||||
|
let dest_parent = TempDir::new().unwrap();
|
||||||
|
let dest = dest_parent.path().join("clone-target");
|
||||||
|
let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap();
|
||||||
|
assert_eq!(cloned.path, dest);
|
||||||
|
assert!(dest.join("seed").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_changes_reports_new_modified_and_deleted() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
|
||||||
|
fs::write(tmp.path().join("added.txt"), "new").unwrap();
|
||||||
|
fs::write(tmp.path().join(".gitkeep"), "modified").unwrap();
|
||||||
|
|
||||||
|
let changes = repo.local_changes().unwrap();
|
||||||
|
assert!(!changes.is_empty());
|
||||||
|
let kinds: Vec<char> = changes.iter().map(|(c, _)| *c).collect();
|
||||||
|
assert!(kinds.contains(&'A'));
|
||||||
|
assert!(kinds.contains(&'M'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_clean_after_commit() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
assert!(repo.is_clean().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_diff_includes_modified_tracked_content() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
// Modify an already-tracked file so it appears in `git diff HEAD`.
|
||||||
|
fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap();
|
||||||
|
|
||||||
|
let diff = repo.working_diff().unwrap();
|
||||||
|
assert!(
|
||||||
|
diff.contains("tracked change"),
|
||||||
|
"diff did not include tracked change, diff was: {diff:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn working_diff_empty_when_only_untracked_files() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap();
|
||||||
|
|
||||||
|
// working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED,
|
||||||
|
// so untracked files don't appear — local_changes is the right tool for that.
|
||||||
|
let diff = repo.working_diff().unwrap();
|
||||||
|
assert!(
|
||||||
|
diff.is_empty() || !diff.contains("new-untracked"),
|
||||||
|
"expected untracked file to be excluded, diff was: {diff:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_remote_overwrites_existing_remote() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||||
|
repo.set_remote("origin", "https://example.com/a.git")
|
||||||
|
.unwrap();
|
||||||
|
// A second call must not error out — it should replace the previous URL.
|
||||||
|
repo.set_remote("origin", "https://example.com/b.git")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_commit_time_returns_none_for_empty_repo() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||||
|
assert!(repo.last_commit_time().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_commit_time_present_after_commit() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
assert!(repo.last_commit_time().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_pull_round_trip_through_bare_remote() {
|
||||||
|
let bare = TempDir::new().unwrap();
|
||||||
|
make_bare_repo(bare.path());
|
||||||
|
|
||||||
|
// Push from author repo.
|
||||||
|
let author = TempDir::new().unwrap();
|
||||||
|
let r1 = SyncRepo::init(author.path()).unwrap();
|
||||||
|
r1.set_remote("origin", bare.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
fs::write(author.path().join("note.txt"), "v1").unwrap();
|
||||||
|
r1.commit("v1").unwrap();
|
||||||
|
r1.push("origin", "main").unwrap();
|
||||||
|
|
||||||
|
// Clone into reader repo and confirm contents.
|
||||||
|
let reader_tmp = TempDir::new().unwrap();
|
||||||
|
let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||||
|
"v1"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Author writes a second version and pushes.
|
||||||
|
fs::write(author.path().join("note.txt"), "v2").unwrap();
|
||||||
|
r1.commit("v2").unwrap();
|
||||||
|
r1.push("origin", "main").unwrap();
|
||||||
|
|
||||||
|
// Reader pulls and sees the new content.
|
||||||
|
r2.pull("origin", "main").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||||
|
"v2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_with_no_remote_changes_is_noop() {
|
||||||
|
let bare = TempDir::new().unwrap();
|
||||||
|
make_bare_repo(bare.path());
|
||||||
|
|
||||||
|
let local = TempDir::new().unwrap();
|
||||||
|
let repo = SyncRepo::init(local.path()).unwrap();
|
||||||
|
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
fs::write(local.path().join("a"), "1").unwrap();
|
||||||
|
repo.commit("c1").unwrap();
|
||||||
|
repo.push("origin", "main").unwrap();
|
||||||
|
|
||||||
|
// Calling pull immediately after push must be up-to-date and succeed.
|
||||||
|
repo.pull("origin", "main").unwrap();
|
||||||
|
assert!(repo.is_clean().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_changes_returns_empty_when_remote_unknown() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = init_repo_with_commit(tmp.path());
|
||||||
|
let changes = repo.remote_changes("origin", "main").unwrap();
|
||||||
|
assert!(changes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── machine list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_list_returns_all_profiles_sorted() {
|
||||||
|
let machines_tmp = TempDir::new().unwrap();
|
||||||
|
for name in ["delta", "alpha", "charlie", "bravo"] {
|
||||||
|
machine::MachineProfile::new(name.to_string(), vec![])
|
||||||
|
.write(machines_tmp.path())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let list = machine::MachineProfile::list(machines_tmp.path()).unwrap();
|
||||||
|
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── packages snapshot ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_writes_destination_when_manager_unknown_is_skipped() {
|
||||||
|
let dest_tmp = TempDir::new().unwrap();
|
||||||
|
let dest = dest_tmp.path().join("nested/dir/file.txt");
|
||||||
|
let wrote = packages::snapshot("does-not-exist", &dest).unwrap();
|
||||||
|
assert!(!wrote);
|
||||||
|
assert!(!dest.exists());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadd"
|
name = "breadd"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
|
bread-sync = { path = "../bread-sync" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
@ -17,7 +18,6 @@ toml = "0.8"
|
||||||
udev = { version = "0.9", features = ["send"] }
|
udev = { version = "0.9", features = ["send"] }
|
||||||
rtnetlink = "0.9"
|
rtnetlink = "0.9"
|
||||||
zbus = { version = "3.13", features = ["tokio"] }
|
zbus = { version = "3.13", features = ["tokio"] }
|
||||||
hex = "0.4"
|
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
netlink-packet-route = "0.11"
|
netlink-packet-route = "0.11"
|
||||||
netlink-packet-core = "0.4"
|
netlink-packet-core = "0.4"
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ fn hyprland_event_socket() -> Result<PathBuf> {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
match sockets.len() {
|
match sockets.len() {
|
||||||
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
|
0 => Err(anyhow!(
|
||||||
|
"no Hyprland instance found in {}",
|
||||||
|
hypr_dir.display()
|
||||||
|
)),
|
||||||
1 => Ok(sockets.remove(0)),
|
1 => Ok(sockets.remove(0)),
|
||||||
n => {
|
n => {
|
||||||
warn!("found {n} Hyprland instances, using first");
|
warn!("found {n} Hyprland instances, using first");
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bread_shared::RawEvent;
|
use bread_shared::RawEvent;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
|
||||||
use tracing::info;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
use crate::core::supervisor::spawn_supervised;
|
use crate::core::supervisor::spawn_supervised;
|
||||||
|
|
||||||
pub mod hyprland;
|
pub mod hyprland;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod power;
|
|
||||||
pub mod udev;
|
|
||||||
pub mod network_rtnetlink;
|
pub mod network_rtnetlink;
|
||||||
|
pub mod power;
|
||||||
pub mod power_upower;
|
pub mod power_upower;
|
||||||
|
pub mod udev;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|
@ -71,7 +71,7 @@ impl Manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.config.adapters.hyprland.enabled {
|
if self.config.adapters.hyprland.enabled {
|
||||||
self.spawn_adapter(hyprland::HyprlandAdapter::default());
|
self.spawn_adapter(hyprland::HyprlandAdapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.config.adapters.power.enabled {
|
if self.config.adapters.power.enabled {
|
||||||
|
|
@ -92,7 +92,7 @@ impl Manager {
|
||||||
if let Ok(adapter) = rt {
|
if let Ok(adapter) = rt {
|
||||||
self.spawn_adapter(adapter);
|
self.spawn_adapter(adapter);
|
||||||
} else {
|
} else {
|
||||||
self.spawn_adapter(network::NetworkAdapter::default());
|
self.spawn_adapter(network::NetworkAdapter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,14 @@ impl Adapter for RtnetlinkAdapter {
|
||||||
"netns_id": netns_id,
|
"netns_id": netns_id,
|
||||||
"netns_fd": netns_fd
|
"netns_fd": netns_fd
|
||||||
});
|
});
|
||||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
|
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
|
||||||
|
|
@ -86,17 +93,32 @@ impl Adapter for RtnetlinkAdapter {
|
||||||
"gateway": gateway_ip,
|
"gateway": gateway_ip,
|
||||||
"table": route.header.table
|
"table": route.header.table
|
||||||
});
|
});
|
||||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: "route.default.changed".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => {
|
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(
|
||||||
|
addr,
|
||||||
|
)) => {
|
||||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
netlink_packet_route::address::nlas::Nla::Address(bytes) => {
|
||||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
Some(bytes.clone())
|
||||||
|
}
|
||||||
|
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
|
||||||
|
Some(bytes.clone())
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||||
|
Some(label.clone())
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||||
|
|
@ -107,16 +129,31 @@ impl Adapter for RtnetlinkAdapter {
|
||||||
"address": ip,
|
"address": ip,
|
||||||
"label": label
|
"label": label
|
||||||
});
|
});
|
||||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: "address.added".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => {
|
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(
|
||||||
|
addr,
|
||||||
|
)) => {
|
||||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
netlink_packet_route::address::nlas::Nla::Address(bytes) => {
|
||||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
Some(bytes.clone())
|
||||||
|
}
|
||||||
|
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
|
||||||
|
Some(bytes.clone())
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||||
|
Some(label.clone())
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||||
|
|
@ -127,7 +164,14 @@ impl Adapter for RtnetlinkAdapter {
|
||||||
"address": ip,
|
"address": ip,
|
||||||
"label": label
|
"label": label
|
||||||
});
|
});
|
||||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: "address.removed".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
debug!("unhandled netlink message");
|
debug!("unhandled netlink message");
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use zbus::{Message, MessageStream};
|
|
||||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||||
|
use zbus::{Message, MessageStream};
|
||||||
|
|
||||||
use super::Adapter;
|
use super::Adapter;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,11 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let id = dev.syspath().to_string_lossy().to_string();
|
let id = dev.syspath().to_string_lossy().to_string();
|
||||||
out.push(ScannedDevice { id, name, subsystem });
|
out.push(ScannedDevice {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
subsystem,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub daemon: DaemonConfig,
|
pub daemon: DaemonConfig,
|
||||||
|
|
@ -45,7 +45,7 @@ pub struct ModulesConfig {
|
||||||
pub disable: Vec<String>,
|
pub disable: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct AdaptersConfig {
|
pub struct AdaptersConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hyprland: AdapterToggle,
|
pub hyprland: AdapterToggle,
|
||||||
|
|
@ -95,19 +95,6 @@ pub struct NotificationsConfig {
|
||||||
pub notify_send_path: String,
|
pub notify_send_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
daemon: DaemonConfig::default(),
|
|
||||||
lua: LuaConfig::default(),
|
|
||||||
modules: ModulesConfig::default(),
|
|
||||||
adapters: AdaptersConfig::default(),
|
|
||||||
notifications: NotificationsConfig::default(),
|
|
||||||
events: EventsConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DaemonConfig {
|
impl Default for DaemonConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -135,17 +122,6 @@ impl Default for ModulesConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AdaptersConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
hyprland: AdapterToggle::default(),
|
|
||||||
udev: UdevConfig::default(),
|
|
||||||
power: PowerConfig::default(),
|
|
||||||
network: AdapterToggle::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AdapterToggle {
|
impl Default for AdapterToggle {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -281,3 +257,241 @@ fn default_udev_subsystems() -> Vec<String> {
|
||||||
"power_supply".to_string(),
|
"power_supply".to_string(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// Tests that mutate process env vars must serialize against each other
|
||||||
|
// — cargo runs tests in parallel by default and HOME/XDG_RUNTIME_DIR are
|
||||||
|
// process-global. Tests that don't touch env are free to run unguarded.
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
struct EnvGuard {
|
||||||
|
saved: Vec<(&'static str, Option<String>)>,
|
||||||
|
_guard: std::sync::MutexGuard<'static, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvGuard {
|
||||||
|
fn new(vars: &[&'static str]) -> Self {
|
||||||
|
let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let saved = vars.iter().map(|k| (*k, std::env::var(k).ok())).collect();
|
||||||
|
Self {
|
||||||
|
saved,
|
||||||
|
_guard: guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for (key, value) in &self.saved {
|
||||||
|
match value {
|
||||||
|
Some(v) => std::env::set_var(key, v),
|
||||||
|
None => std::env::remove_var(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_uses_documented_defaults() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.daemon.log_level, "info");
|
||||||
|
assert!(cfg.daemon.socket_path.is_empty());
|
||||||
|
assert_eq!(cfg.lua.entry_point, "~/.config/bread/init.lua");
|
||||||
|
assert_eq!(cfg.lua.module_path, "~/.config/bread/modules");
|
||||||
|
assert!(cfg.adapters.hyprland.enabled);
|
||||||
|
assert!(cfg.adapters.udev.enabled);
|
||||||
|
assert!(cfg.adapters.power.enabled);
|
||||||
|
assert!(cfg.adapters.network.enabled);
|
||||||
|
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
|
||||||
|
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||||
|
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
|
||||||
|
assert_eq!(cfg.notifications.default_urgency, "normal");
|
||||||
|
assert_eq!(cfg.notifications.notify_send_path, "notify-send");
|
||||||
|
assert!(cfg.modules.builtin);
|
||||||
|
assert!(cfg.modules.disable.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_udev_subsystems_match_documented_list() {
|
||||||
|
assert_eq!(
|
||||||
|
default_udev_subsystems(),
|
||||||
|
vec!["usb", "input", "drm", "power_supply"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_toml_yields_defaults() {
|
||||||
|
let cfg: Config = toml::from_str("").unwrap();
|
||||||
|
assert_eq!(cfg.daemon.log_level, "info");
|
||||||
|
assert!(cfg.adapters.hyprland.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_full_toml_overrides_all_values() {
|
||||||
|
let raw = r#"
|
||||||
|
[daemon]
|
||||||
|
log_level = "debug"
|
||||||
|
socket_path = "/tmp/custom.sock"
|
||||||
|
|
||||||
|
[lua]
|
||||||
|
entry_point = "/abs/init.lua"
|
||||||
|
module_path = "/abs/mods"
|
||||||
|
|
||||||
|
[modules]
|
||||||
|
builtin = false
|
||||||
|
disable = ["foo", "bar"]
|
||||||
|
|
||||||
|
[adapters.hyprland]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[adapters.udev]
|
||||||
|
enabled = true
|
||||||
|
subsystems = ["usb"]
|
||||||
|
|
||||||
|
[adapters.power]
|
||||||
|
enabled = false
|
||||||
|
poll_interval_secs = 5
|
||||||
|
|
||||||
|
[adapters.network]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[events]
|
||||||
|
dedup_window_ms = 250
|
||||||
|
|
||||||
|
[notifications]
|
||||||
|
default_timeout_ms = 1000
|
||||||
|
default_urgency = "critical"
|
||||||
|
notify_send_path = "/usr/local/bin/notify-send"
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(raw).unwrap();
|
||||||
|
assert_eq!(cfg.daemon.log_level, "debug");
|
||||||
|
assert_eq!(cfg.daemon.socket_path, "/tmp/custom.sock");
|
||||||
|
assert_eq!(cfg.lua.entry_point, "/abs/init.lua");
|
||||||
|
assert_eq!(cfg.lua.module_path, "/abs/mods");
|
||||||
|
assert!(!cfg.modules.builtin);
|
||||||
|
assert_eq!(cfg.modules.disable, vec!["foo", "bar"]);
|
||||||
|
assert!(!cfg.adapters.hyprland.enabled);
|
||||||
|
assert!(cfg.adapters.udev.enabled);
|
||||||
|
assert_eq!(cfg.adapters.udev.subsystems, vec!["usb"]);
|
||||||
|
assert!(!cfg.adapters.power.enabled);
|
||||||
|
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
|
||||||
|
assert!(!cfg.adapters.network.enabled);
|
||||||
|
assert_eq!(cfg.events.dedup_window_ms, 250);
|
||||||
|
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
||||||
|
assert_eq!(cfg.notifications.default_urgency, "critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_partial_toml_fills_missing_with_defaults() {
|
||||||
|
let raw = r#"
|
||||||
|
[daemon]
|
||||||
|
log_level = "trace"
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(raw).unwrap();
|
||||||
|
assert_eq!(cfg.daemon.log_level, "trace");
|
||||||
|
// Untouched sections still get their defaults.
|
||||||
|
assert!(cfg.adapters.hyprland.enabled);
|
||||||
|
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_toml_returns_error() {
|
||||||
|
let result: Result<Config, _> = toml::from_str("[daemon\nbroken");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_uses_explicit_path_verbatim() {
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.daemon.socket_path = "/run/bread.sock".to_string();
|
||||||
|
assert_eq!(cfg.socket_path(), PathBuf::from("/run/bread.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_expands_tilde_when_explicit() {
|
||||||
|
let _g = EnvGuard::new(&["HOME"]);
|
||||||
|
std::env::set_var("HOME", "/synthetic/home");
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.daemon.socket_path = "~/sockets/bread.sock".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.socket_path(),
|
||||||
|
PathBuf::from("/synthetic/home/sockets/bread.sock")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_falls_back_to_xdg_runtime_dir() {
|
||||||
|
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
|
||||||
|
std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg");
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.socket_path(),
|
||||||
|
PathBuf::from("/tmp/xdg/bread/breadd.sock")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_uses_tmp_when_no_xdg_runtime_dir() {
|
||||||
|
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
|
||||||
|
std::env::remove_var("XDG_RUNTIME_DIR");
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.socket_path(), PathBuf::from("/tmp/bread/breadd.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_entry_point_and_module_path_expand_tilde() {
|
||||||
|
let _g = EnvGuard::new(&["HOME"]);
|
||||||
|
std::env::set_var("HOME", "/synthetic/home");
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.lua_entry_point(),
|
||||||
|
PathBuf::from("/synthetic/home/.config/bread/init.lua")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.lua_module_path(),
|
||||||
|
PathBuf::from("/synthetic/home/.config/bread/modules")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_entry_point_returns_absolute_path_unchanged() {
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.lua.entry_point = "/etc/bread/init.lua".to_string();
|
||||||
|
assert_eq!(cfg.lua_entry_point(), PathBuf::from("/etc/bread/init.lua"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_home_handles_missing_home_env() {
|
||||||
|
let _g = EnvGuard::new(&["HOME"]);
|
||||||
|
std::env::remove_var("HOME");
|
||||||
|
// Without HOME, ~/-prefixed paths fall back to the literal string.
|
||||||
|
assert_eq!(expand_home("~/foo"), PathBuf::from("~/foo"));
|
||||||
|
// Non-tilde paths are unchanged regardless.
|
||||||
|
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_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!(
|
||||||
|
config_path(),
|
||||||
|
PathBuf::from("/synthetic/xdg-config/bread/breadd.toml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_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!(
|
||||||
|
config_path(),
|
||||||
|
PathBuf::from("/synthetic/home/.config/bread/breadd.toml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ impl EventNormalizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
|
let action = raw
|
||||||
|
.payload
|
||||||
|
.get("action")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("change");
|
||||||
|
|
||||||
// "bind" is the kernel attaching a driver to an interface — not a meaningful
|
// "bind" is the kernel attaching a driver to an interface — not a meaningful
|
||||||
// device state change for automation purposes.
|
// device state change for automation purposes.
|
||||||
|
|
@ -52,11 +56,31 @@ impl EventNormalizer {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = raw.payload.get("name").and_then(Value::as_str).unwrap_or("unknown");
|
let name = raw
|
||||||
let vendor = raw.payload.get("id_vendor").and_then(Value::as_str).unwrap_or_default();
|
.payload
|
||||||
let vendor_id = raw.payload.get("vendor_id").and_then(Value::as_str).unwrap_or_default();
|
.get("name")
|
||||||
let product_id = raw.payload.get("product_id").and_then(Value::as_str).unwrap_or_default();
|
.and_then(Value::as_str)
|
||||||
let subsystem = raw.payload.get("subsystem").and_then(Value::as_str).unwrap_or_default();
|
.unwrap_or("unknown");
|
||||||
|
let vendor = raw
|
||||||
|
.payload
|
||||||
|
.get("id_vendor")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let vendor_id = raw
|
||||||
|
.payload
|
||||||
|
.get("vendor_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let product_id = raw
|
||||||
|
.payload
|
||||||
|
.get("product_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let subsystem = raw
|
||||||
|
.payload
|
||||||
|
.get("subsystem")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
|
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
|
||||||
// no identity information — they are USB protocol artefacts, not devices.
|
// no identity information — they are USB protocol artefacts, not devices.
|
||||||
|
|
@ -74,7 +98,10 @@ impl EventNormalizer {
|
||||||
_ => "changed",
|
_ => "changed",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() {
|
if (verb == "connected" || verb == "disconnected")
|
||||||
|
&& !vendor_id.is_empty()
|
||||||
|
&& !product_id.is_empty()
|
||||||
|
{
|
||||||
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
|
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
|
||||||
let now = raw.timestamp;
|
let now = raw.timestamp;
|
||||||
let already_seen = {
|
let already_seen = {
|
||||||
|
|
@ -89,13 +116,18 @@ impl EventNormalizer {
|
||||||
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
|
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
|
||||||
seen.insert(device_key, now);
|
seen.insert(device_key, now);
|
||||||
// Evict stale entries
|
// Evict stale entries
|
||||||
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
let evict_before =
|
||||||
|
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||||
if evict_before > 0 {
|
if evict_before > 0 {
|
||||||
seen.retain(|_, &mut last| last >= evict_before);
|
seen.retain(|_, &mut last| last >= evict_before);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
let id = raw
|
||||||
|
.payload
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
// Device name is always "unknown" here; the state engine applies user-defined
|
// Device name is always "unknown" here; the state engine applies user-defined
|
||||||
// classification rules from devices.lua before dispatching to subscribers.
|
// classification rules from devices.lua before dispatching to subscribers.
|
||||||
|
|
@ -117,7 +149,11 @@ impl EventNormalizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
|
let kind = raw
|
||||||
|
.payload
|
||||||
|
.get("kind")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
let data = raw
|
let data = raw
|
||||||
.payload
|
.payload
|
||||||
.get("data")
|
.get("data")
|
||||||
|
|
@ -168,7 +204,7 @@ impl EventNormalizer {
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: json!({
|
data: json!({
|
||||||
"address": fields.get(0).unwrap_or(&"")
|
"address": fields.first().unwrap_or(&"")
|
||||||
}),
|
}),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +215,7 @@ impl EventNormalizer {
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: json!({
|
data: json!({
|
||||||
"address": fields.get(0).unwrap_or(&""),
|
"address": fields.first().unwrap_or(&""),
|
||||||
"workspace": fields.get(1).unwrap_or(&""),
|
"workspace": fields.get(1).unwrap_or(&""),
|
||||||
"class": fields.get(2).unwrap_or(&""),
|
"class": fields.get(2).unwrap_or(&""),
|
||||||
"title": fields.get(3).unwrap_or(&""),
|
"title": fields.get(3).unwrap_or(&""),
|
||||||
|
|
@ -192,7 +228,7 @@ impl EventNormalizer {
|
||||||
event: "bread.window.closed".to_string(),
|
event: "bread.window.closed".to_string(),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: json!({ "address": fields.get(0).unwrap_or(&"") }),
|
data: json!({ "address": fields.first().unwrap_or(&"") }),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
"movewindow" => {
|
"movewindow" => {
|
||||||
|
|
@ -202,7 +238,7 @@ impl EventNormalizer {
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: json!({
|
data: json!({
|
||||||
"address": fields.get(0).unwrap_or(&""),
|
"address": fields.first().unwrap_or(&""),
|
||||||
"workspace": fields.get(1).unwrap_or(&""),
|
"workspace": fields.get(1).unwrap_or(&""),
|
||||||
}),
|
}),
|
||||||
}]
|
}]
|
||||||
|
|
@ -268,7 +304,11 @@ impl EventNormalizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
|
let online = raw
|
||||||
|
.payload
|
||||||
|
.get("online")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
let name = if online {
|
let name = if online {
|
||||||
"bread.network.connected"
|
"bread.network.connected"
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -310,7 +350,8 @@ impl EventNormalizer {
|
||||||
recent.insert(key.clone(), now);
|
recent.insert(key.clone(), now);
|
||||||
|
|
||||||
// Evict stale entries to prevent unbounded growth.
|
// Evict stale entries to prevent unbounded growth.
|
||||||
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
let evict_before =
|
||||||
|
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||||
if evict_before > 0 {
|
if evict_before > 0 {
|
||||||
recent.retain(|_, &mut last| last >= evict_before);
|
recent.retain(|_, &mut last| last >= evict_before);
|
||||||
}
|
}
|
||||||
|
|
@ -326,3 +367,403 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
|
||||||
data.split(">>").collect()
|
data.split(">>").collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent {
|
||||||
|
RawEvent {
|
||||||
|
source,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: ts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Udev ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_add_emits_connected_with_identity_fields() {
|
||||||
|
let n = EventNormalizer::new(100);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({
|
||||||
|
"action": "add",
|
||||||
|
"name": "Logitech Mouse",
|
||||||
|
"id_vendor": "Logitech",
|
||||||
|
"vendor_id": "046d",
|
||||||
|
"product_id": "c52b",
|
||||||
|
"subsystem": "usb",
|
||||||
|
"id": "1-1.4",
|
||||||
|
}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.device.connected");
|
||||||
|
assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d");
|
||||||
|
assert_eq!(out[0].data.get("product_id").unwrap(), "c52b");
|
||||||
|
assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse");
|
||||||
|
assert_eq!(out[0].data.get("subsystem").unwrap(), "usb");
|
||||||
|
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_remove_emits_disconnected() {
|
||||||
|
let n = EventNormalizer::new(100);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({
|
||||||
|
"action": "remove",
|
||||||
|
"name": "Logitech",
|
||||||
|
"vendor_id": "046d",
|
||||||
|
"product_id": "c52b",
|
||||||
|
"subsystem": "usb",
|
||||||
|
"id": "1-1.4",
|
||||||
|
}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.device.disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_bind_action_is_suppressed() {
|
||||||
|
let n = EventNormalizer::new(100);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({
|
||||||
|
"action": "bind",
|
||||||
|
"name": "x",
|
||||||
|
"vendor_id": "046d",
|
||||||
|
"product_id": "c52b",
|
||||||
|
}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
assert!(n.normalize(&ev).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_anonymous_child_interface_is_dropped() {
|
||||||
|
let n = EventNormalizer::new(100);
|
||||||
|
// No name, no vendor — pure USB protocol artefact.
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({
|
||||||
|
"action": "add",
|
||||||
|
"id": "3-5:1.0",
|
||||||
|
}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
assert!(n.normalize(&ev).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_dedupes_child_nodes_of_same_physical_device() {
|
||||||
|
let n = EventNormalizer::new(1000);
|
||||||
|
let mk = |id: &str, ts: u64| {
|
||||||
|
raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({
|
||||||
|
"action": "add",
|
||||||
|
"name": "Hub Device",
|
||||||
|
"vendor_id": "1d6b",
|
||||||
|
"product_id": "0002",
|
||||||
|
"subsystem": "usb",
|
||||||
|
"id": id,
|
||||||
|
}),
|
||||||
|
ts,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// First child fires
|
||||||
|
assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1);
|
||||||
|
// Sibling within window is suppressed
|
||||||
|
assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0);
|
||||||
|
// After the dedup window, a sibling fires again
|
||||||
|
assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udev_disconnect_does_not_share_dedup_with_connect() {
|
||||||
|
let n = EventNormalizer::new(1000);
|
||||||
|
let connect = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let disconnect = raw(
|
||||||
|
AdapterSource::Udev,
|
||||||
|
"udev",
|
||||||
|
json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
|
||||||
|
1100,
|
||||||
|
);
|
||||||
|
assert_eq!(n.normalize(&connect).len(), 1);
|
||||||
|
// Disconnect uses a different verb in the dedup key, so it fires.
|
||||||
|
assert_eq!(n.normalize(&disconnect).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hyprland ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_workspace_change() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "workspace", "data": "2"}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.workspace.changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_active_window_v2_parses_address_from_fields() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "activewindowv2", "data": "0xdeadbeef"}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.window.focused");
|
||||||
|
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_openwindow_splits_all_fields() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.window.opened");
|
||||||
|
let d = &out[0].data;
|
||||||
|
assert_eq!(d.get("address").unwrap(), "0xabc");
|
||||||
|
assert_eq!(d.get("workspace").unwrap(), "2");
|
||||||
|
assert_eq!(d.get("class").unwrap(), "firefox");
|
||||||
|
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_unknown_kind_falls_through_to_generic_event() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "submap", "data": "resize"}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&ev);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.hyprland.event");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_monitor_lifecycle() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let added = n.normalize(&raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "monitoradded", "data": "HDMI-A-1"}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let removed = n.normalize(&raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
assert_eq!(added[0].event, "bread.monitor.connected");
|
||||||
|
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1");
|
||||||
|
assert_eq!(removed[0].event, "bread.monitor.disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Power ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_ac_connected_emits_named_event() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Power,
|
||||||
|
"power",
|
||||||
|
json!({"ac_connected": true}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.power.ac.connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_battery_thresholds_select_correct_event() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let cases = [
|
||||||
|
(3, "bread.power.battery.critical"),
|
||||||
|
(5, "bread.power.battery.critical"),
|
||||||
|
(8, "bread.power.battery.very_low"),
|
||||||
|
(10, "bread.power.battery.very_low"),
|
||||||
|
(15, "bread.power.battery.low"),
|
||||||
|
(20, "bread.power.battery.low"),
|
||||||
|
(100, "bread.power.battery.full"),
|
||||||
|
];
|
||||||
|
for (level, expected) in cases {
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Power,
|
||||||
|
"power",
|
||||||
|
json!({"battery_percent": level}),
|
||||||
|
level * 1000,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
out[0].event, expected,
|
||||||
|
"level {level} should map to {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_mid_range_battery_emits_generic_changed() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Power,
|
||||||
|
"power",
|
||||||
|
json!({"battery_percent": 50}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.power.changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_ac_and_battery_can_both_fire() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Power,
|
||||||
|
"power",
|
||||||
|
json!({"ac_connected": false, "battery_percent": 4}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect();
|
||||||
|
assert!(names.contains(&"bread.power.ac.disconnected"));
|
||||||
|
assert!(names.contains(&"bread.power.battery.critical"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Network ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_online_and_offline() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let online = n.normalize(&raw(
|
||||||
|
AdapterSource::Network,
|
||||||
|
"net",
|
||||||
|
json!({"online": true}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let offline = n.normalize(&raw(
|
||||||
|
AdapterSource::Network,
|
||||||
|
"net",
|
||||||
|
json!({"online": false}),
|
||||||
|
2,
|
||||||
|
));
|
||||||
|
assert_eq!(online[0].event, "bread.network.connected");
|
||||||
|
assert_eq!(offline[0].event, "bread.network.disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── System pass-through ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_events_pass_through_unchanged() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::System,
|
||||||
|
"bread.custom.event",
|
||||||
|
json!({"foo": "bar"}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.custom.event");
|
||||||
|
assert_eq!(out[0].source, AdapterSource::System);
|
||||||
|
assert_eq!(out[0].data.get("foo").unwrap(), "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dedup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_drops_duplicate_within_window() {
|
||||||
|
let n = EventNormalizer::new(500);
|
||||||
|
let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
|
||||||
|
assert_eq!(n.normalize(&ev).len(), 1);
|
||||||
|
|
||||||
|
let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200);
|
||||||
|
assert_eq!(n.normalize(&dup).len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_allows_after_window_elapses() {
|
||||||
|
let n = EventNormalizer::new(500);
|
||||||
|
let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
|
||||||
|
assert_eq!(n.normalize(&first).len(), 1);
|
||||||
|
|
||||||
|
let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000);
|
||||||
|
assert_eq!(n.normalize(&later).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_distinguishes_different_payloads() {
|
||||||
|
let n = EventNormalizer::new(10_000);
|
||||||
|
let a = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "workspace", "data": "1"}),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
let b = raw(
|
||||||
|
AdapterSource::Hyprland,
|
||||||
|
"hypr",
|
||||||
|
json!({"kind": "workspace", "data": "2"}),
|
||||||
|
1100,
|
||||||
|
);
|
||||||
|
assert_eq!(n.normalize(&a).len(), 1);
|
||||||
|
// Different payloads = different dedup key
|
||||||
|
assert_eq!(n.normalize(&b).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_window_of_zero_allows_everything() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(
|
||||||
|
n.normalize(&raw(
|
||||||
|
AdapterSource::Network,
|
||||||
|
"net",
|
||||||
|
json!({"online": true}),
|
||||||
|
1000,
|
||||||
|
))
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_fields_handles_empty_and_single() {
|
||||||
|
assert!(split_hyprland_fields("").is_empty());
|
||||||
|
assert_eq!(split_hyprland_fields("only"), vec!["only"]);
|
||||||
|
assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bread_shared::{AdapterSource, BreadEvent};
|
use bread_shared::{AdapterSource, BreadEvent};
|
||||||
|
|
@ -9,14 +9,15 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
||||||
use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState};
|
use crate::core::types::{
|
||||||
|
Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState,
|
||||||
|
};
|
||||||
use crate::lua::LuaMessage;
|
use crate::lua::LuaMessage;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StateHandle {
|
pub struct StateHandle {
|
||||||
state: Arc<RwLock<RuntimeState>>,
|
state: Arc<RwLock<RuntimeState>>,
|
||||||
command_tx: mpsc::UnboundedSender<StateCommand>,
|
command_tx: mpsc::UnboundedSender<StateCommand>,
|
||||||
subscription_count: Arc<AtomicU64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum StateCommand {
|
pub enum StateCommand {
|
||||||
|
|
@ -53,13 +54,8 @@ impl StateHandle {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
state: Arc<RwLock<RuntimeState>>,
|
state: Arc<RwLock<RuntimeState>>,
|
||||||
command_tx: mpsc::UnboundedSender<StateCommand>,
|
command_tx: mpsc::UnboundedSender<StateCommand>,
|
||||||
subscription_count: Arc<AtomicU64>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { state, command_tx }
|
||||||
state,
|
|
||||||
command_tx,
|
|
||||||
subscription_count,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
|
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
|
||||||
|
|
@ -86,18 +82,21 @@ impl StateHandle {
|
||||||
serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({}))
|
serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_subscription(&self, id: SubscriptionId, pattern: String, once: bool) -> Result<()> {
|
pub fn register_subscription(
|
||||||
|
&self,
|
||||||
|
id: SubscriptionId,
|
||||||
|
pattern: String,
|
||||||
|
once: bool,
|
||||||
|
) -> Result<()> {
|
||||||
self.command_tx
|
self.command_tx
|
||||||
.send(StateCommand::RegisterSubscription {
|
.send(StateCommand::RegisterSubscription { id, pattern, once })
|
||||||
id,
|
|
||||||
pattern,
|
|
||||||
once,
|
|
||||||
})
|
|
||||||
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
|
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_subscription(&self, id: SubscriptionId) {
|
pub fn remove_subscription(&self, id: SubscriptionId) {
|
||||||
let _ = self.command_tx.send(StateCommand::RemoveSubscription { id });
|
let _ = self
|
||||||
|
.command_tx
|
||||||
|
.send(StateCommand::RemoveSubscription { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> {
|
pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> {
|
||||||
|
|
@ -140,10 +139,6 @@ impl StateHandle {
|
||||||
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
|
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
|
||||||
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
|
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscription_count(&self) -> Arc<AtomicU64> {
|
|
||||||
self.subscription_count.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_state_engine(
|
pub async fn run_state_engine(
|
||||||
|
|
@ -376,8 +371,16 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||||
state.monitors.push(crate::core::types::Monitor {
|
state.monitors.push(crate::core::types::Monitor {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
connected: true,
|
connected: true,
|
||||||
resolution: event.data.get("resolution").and_then(Value::as_str).map(ToString::to_string),
|
resolution: event
|
||||||
position: event.data.get("position").and_then(Value::as_str).map(ToString::to_string),
|
.data
|
||||||
|
.get("resolution")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string),
|
||||||
|
position: event
|
||||||
|
.data
|
||||||
|
.get("position")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +424,10 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||||
state.network.interfaces.clear();
|
state.network.interfaces.clear();
|
||||||
for (name, meta) in ifaces {
|
for (name, meta) in ifaces {
|
||||||
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
|
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
|
||||||
state.network.interfaces.insert(name.clone(), InterfaceState { up });
|
state
|
||||||
|
.network
|
||||||
|
.interfaces
|
||||||
|
.insert(name.clone(), InterfaceState { up });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +461,8 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||||
|
|
||||||
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
|
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) {
|
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data))
|
||||||
|
{
|
||||||
return rule.device.clone();
|
return rule.device.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -476,37 +483,68 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref expected) = cond.name {
|
if let Some(ref expected) = cond.name {
|
||||||
let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let actual = data
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
if actual != expected.to_lowercase() {
|
if actual != expected.to_lowercase() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref expected) = cond.vendor {
|
if let Some(ref expected) = cond.vendor {
|
||||||
let actual = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let actual = data
|
||||||
|
.get("vendor")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
if actual != expected.to_lowercase() {
|
if actual != expected.to_lowercase() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref contains) = cond.name_contains {
|
if let Some(ref contains) = cond.name_contains {
|
||||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let name = data
|
||||||
let vendor = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let vendor = data
|
||||||
|
.get("vendor")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
let combined = format!("{name} {vendor}");
|
let combined = format!("{name} {vendor}");
|
||||||
if !combined.contains(contains.to_lowercase().as_str()) {
|
if !combined.contains(contains.to_lowercase().as_str()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(expected) = cond.id_input_keyboard {
|
if let Some(expected) = cond.id_input_keyboard {
|
||||||
if data.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) != expected {
|
if data
|
||||||
|
.get("id_input_keyboard")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
!= expected
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(expected) = cond.id_input_mouse {
|
if let Some(expected) = cond.id_input_mouse {
|
||||||
if data.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) != expected {
|
if data
|
||||||
|
.get("id_input_mouse")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
!= expected
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(expected) = cond.id_input_tablet {
|
if let Some(expected) = cond.id_input_tablet {
|
||||||
if data.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) != expected {
|
if data
|
||||||
|
.get("id_input_tablet")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
!= expected
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +564,10 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref expected) = cond.id_usb_class {
|
if let Some(ref expected) = cond.id_usb_class {
|
||||||
let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or("");
|
let actual = data
|
||||||
|
.get("id_usb_class")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
if actual.to_lowercase() != expected.to_lowercase()
|
if actual.to_lowercase() != expected.to_lowercase()
|
||||||
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
|
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
|
||||||
{
|
{
|
||||||
|
|
@ -534,7 +575,11 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref expected) = cond.subsystem {
|
if let Some(ref expected) = cond.subsystem {
|
||||||
let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let actual = data
|
||||||
|
.get("subsystem")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
if actual != expected.to_lowercase() {
|
if actual != expected.to_lowercase() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -586,3 +631,407 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
||||||
state.devices.connected.retain(|d| d.id != id);
|
state.devices.connected.retain(|d| d.id != id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ev(name: &str, data: Value) -> BreadEvent {
|
||||||
|
BreadEvent {
|
||||||
|
event: name.to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
source: AdapterSource::System,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── value_at_path ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_at_path_returns_root_for_empty_path() {
|
||||||
|
let v = json!({"a": 1});
|
||||||
|
assert_eq!(value_at_path(&v, ""), Some(json!({"a": 1})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_at_path_navigates_nested_keys() {
|
||||||
|
let v = json!({"a": {"b": {"c": 42}}});
|
||||||
|
assert_eq!(value_at_path(&v, "a.b.c"), Some(json!(42)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_at_path_returns_none_on_missing_key() {
|
||||||
|
let v = json!({"a": 1});
|
||||||
|
assert!(value_at_path(&v, "missing").is_none());
|
||||||
|
assert!(value_at_path(&v, "a.b.c").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_event_to_state: monitors ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_connect_adds_new_monitor() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.monitor.connected",
|
||||||
|
json!({"name": "DP-1", "resolution": "1920x1080", "position": "0x0"}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(state.monitors.len(), 1);
|
||||||
|
assert_eq!(state.monitors[0].name, "DP-1");
|
||||||
|
assert!(state.monitors[0].connected);
|
||||||
|
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1080"));
|
||||||
|
assert_eq!(state.monitors[0].position.as_deref(), Some("0x0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_reconnect_does_not_duplicate() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
let mk = || ev("bread.monitor.connected", json!({"name": "DP-1"}));
|
||||||
|
apply_event_to_state(&mut state, &mk());
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.monitor.disconnected", json!({"name": "DP-1"})),
|
||||||
|
);
|
||||||
|
apply_event_to_state(&mut state, &mk());
|
||||||
|
assert_eq!(state.monitors.len(), 1);
|
||||||
|
assert!(state.monitors[0].connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monitor_disconnect_keeps_record_but_flips_connected_flag() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.monitor.connected", json!({"name": "DP-1"})),
|
||||||
|
);
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.monitor.disconnected", json!({"name": "DP-1"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.monitors.len(), 1);
|
||||||
|
assert!(!state.monitors[0].connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_event_to_state: workspace + window ─────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_changed_updates_active_workspace() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.workspace.changed", json!({"workspace": "3"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_workspace.as_deref(), Some("3"));
|
||||||
|
// Falls back to `id` when `workspace` is absent.
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.workspace.changed", json!({"id": "5"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_workspace.as_deref(), Some("5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_focus_change_updates_active_window() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.window.focus.changed", json!({"window": "firefox"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_window.as_deref(), Some("firefox"));
|
||||||
|
// Falls back to `class`, then `address`.
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.window.focused", json!({"address": "0xdeadbeef"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_device_change ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_connect_adds_device_with_all_fields() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_device_change(
|
||||||
|
&mut state,
|
||||||
|
&json!({
|
||||||
|
"id": "1-1.4",
|
||||||
|
"name": "Logitech Mouse",
|
||||||
|
"device": "mouse",
|
||||||
|
"subsystem": "usb",
|
||||||
|
"vendor_id": "046d",
|
||||||
|
"product_id": "c52b",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(state.devices.connected.len(), 1);
|
||||||
|
let d = &state.devices.connected[0];
|
||||||
|
assert_eq!(d.id, "1-1.4");
|
||||||
|
assert_eq!(d.name, "Logitech Mouse");
|
||||||
|
assert_eq!(d.device, "mouse");
|
||||||
|
assert_eq!(d.subsystem, "usb");
|
||||||
|
assert_eq!(d.vendor_id.as_deref(), Some("046d"));
|
||||||
|
assert_eq!(d.product_id.as_deref(), Some("c52b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_connect_is_idempotent_for_same_id() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
let data = json!({"id": "x", "device": "dock", "name": "Dock"});
|
||||||
|
apply_device_change(&mut state, &data, true);
|
||||||
|
apply_device_change(&mut state, &data, true);
|
||||||
|
assert_eq!(state.devices.connected.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_disconnect_removes_matching_id() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true);
|
||||||
|
apply_device_change(&mut state, &json!({"id": "b", "device": "y"}), true);
|
||||||
|
assert_eq!(state.devices.connected.len(), 2);
|
||||||
|
|
||||||
|
apply_device_change(&mut state, &json!({"id": "a"}), false);
|
||||||
|
assert_eq!(state.devices.connected.len(), 1);
|
||||||
|
assert_eq!(state.devices.connected[0].id, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_disconnect_of_unknown_id_is_noop() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true);
|
||||||
|
apply_device_change(&mut state, &json!({"id": "ghost"}), false);
|
||||||
|
assert_eq!(state.devices.connected.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_event_to_state: power ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_event_updates_ac_and_battery_low_flag() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.power.battery.low",
|
||||||
|
json!({"ac_connected": false, "battery_percent": 18}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(!state.power.ac_connected);
|
||||||
|
assert_eq!(state.power.battery_percent, Some(18));
|
||||||
|
assert!(state.power.battery_low);
|
||||||
|
|
||||||
|
// 25% is no longer "low"
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.power.changed", json!({"battery_percent": 25})),
|
||||||
|
);
|
||||||
|
assert!(!state.power.battery_low);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn power_clamps_battery_percent_to_100() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.power.changed", json!({"battery_percent": 250u64})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.power.battery_percent, Some(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_event_to_state: network ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_event_updates_online_flag_and_interfaces() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.network.connected",
|
||||||
|
json!({
|
||||||
|
"online": true,
|
||||||
|
"interfaces": {
|
||||||
|
"wlan0": {"up": true},
|
||||||
|
"eth0": {"up": false},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(state.network.online);
|
||||||
|
assert_eq!(state.network.interfaces.len(), 2);
|
||||||
|
assert!(state.network.interfaces["wlan0"].up);
|
||||||
|
assert!(!state.network.interfaces["eth0"].up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── apply_event_to_state: profile ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_activated_pushes_previous_to_history() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
// Initial active is "default".
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.profile.activated", json!({"name": "battery"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.profile.active, "battery");
|
||||||
|
assert_eq!(state.profile.history, vec!["default"]);
|
||||||
|
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.profile.activated", json!({"name": "ac"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.profile.active, "ac");
|
||||||
|
assert_eq!(state.profile.history, vec!["default", "battery"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_activated_to_same_name_is_noop() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.profile.activated", json!({"name": "default"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.profile.active, "default");
|
||||||
|
assert!(state.profile.history.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_event_does_not_mutate_state() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
let before = serde_json::to_value(&state).unwrap();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.unknown.event", json!({"foo": "bar"})),
|
||||||
|
);
|
||||||
|
let after = serde_json::to_value(&state).unwrap();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── condition_matches ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_vendor_id_matches_case_insensitively() {
|
||||||
|
let cond = MatchCondition {
|
||||||
|
vendor_id: Some("046D".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(condition_matches(&cond, &json!({"vendor_id": "046d"})));
|
||||||
|
assert!(!condition_matches(&cond, &json!({"vendor_id": "1234"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_name_contains_searches_name_and_vendor() {
|
||||||
|
let cond = MatchCondition {
|
||||||
|
name_contains: Some("logi".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(condition_matches(&cond, &json!({"name": "Logitech MX"})));
|
||||||
|
assert!(condition_matches(&cond, &json!({"vendor": "Logitech Inc"})));
|
||||||
|
assert!(!condition_matches(&cond, &json!({"name": "Apple"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_input_flags_match_booleans() {
|
||||||
|
let cond = MatchCondition {
|
||||||
|
id_input_keyboard: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(condition_matches(
|
||||||
|
&cond,
|
||||||
|
&json!({"id_input_keyboard": true})
|
||||||
|
));
|
||||||
|
assert!(!condition_matches(
|
||||||
|
&cond,
|
||||||
|
&json!({"id_input_keyboard": false})
|
||||||
|
));
|
||||||
|
// Missing field defaults to false.
|
||||||
|
assert!(!condition_matches(&cond, &json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_usb_hub_requires_hub_and_secondary_class() {
|
||||||
|
let cond = MatchCondition {
|
||||||
|
usb_hub: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(condition_matches(
|
||||||
|
&cond,
|
||||||
|
&json!({"id_usb_interfaces": ":0900:0e00:"})
|
||||||
|
));
|
||||||
|
// Hub alone is not enough.
|
||||||
|
assert!(!condition_matches(
|
||||||
|
&cond,
|
||||||
|
&json!({"id_usb_interfaces": ":0900:"})
|
||||||
|
));
|
||||||
|
// Secondary alone is not enough.
|
||||||
|
assert!(!condition_matches(
|
||||||
|
&cond,
|
||||||
|
&json!({"id_usb_interfaces": ":0e00:"})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_id_usb_class_accepts_with_or_without_0x_prefix() {
|
||||||
|
let cond = MatchCondition {
|
||||||
|
id_usb_class: Some("0e".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(condition_matches(&cond, &json!({"id_usb_class": "0e"})));
|
||||||
|
assert!(condition_matches(&cond, &json!({"id_usb_class": "0x0e"})));
|
||||||
|
assert!(!condition_matches(&cond, &json!({"id_usb_class": "ff"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condition_empty_matches_anything() {
|
||||||
|
let cond = MatchCondition::default();
|
||||||
|
assert!(condition_matches(&cond, &json!({})));
|
||||||
|
assert!(condition_matches(&cond, &json!({"vendor_id": "anything"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolve_device ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_device_returns_first_matching_rule() {
|
||||||
|
let rules = vec![
|
||||||
|
DeviceRule {
|
||||||
|
device: "mouse".to_string(),
|
||||||
|
conditions: vec![MatchCondition {
|
||||||
|
vendor_id: Some("046d".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
DeviceRule {
|
||||||
|
device: "dock".to_string(),
|
||||||
|
conditions: vec![MatchCondition {
|
||||||
|
vendor_id: Some("17ef".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_device(&rules, &json!({"vendor_id": "046d"})),
|
||||||
|
"mouse"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_device(&rules, &json!({"vendor_id": "17ef"})),
|
||||||
|
"dock"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_device(&rules, &json!({"vendor_id": "0000"})),
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_device_skips_rules_with_no_conditions() {
|
||||||
|
let rules = vec![DeviceRule {
|
||||||
|
device: "wildcard".to_string(),
|
||||||
|
conditions: vec![],
|
||||||
|
}];
|
||||||
|
assert_eq!(resolve_device(&rules, &json!({})), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_device_with_empty_ruleset_returns_unknown() {
|
||||||
|
assert_eq!(resolve_device(&[], &json!({"vendor_id": "x"})), "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,12 @@ pub struct SubscriptionTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubscriptionTable {
|
impl SubscriptionTable {
|
||||||
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId {
|
pub fn add_with_id(
|
||||||
|
&mut self,
|
||||||
|
id: SubscriptionId,
|
||||||
|
pattern: String,
|
||||||
|
once: bool,
|
||||||
|
) -> SubscriptionId {
|
||||||
self.next_id = self.next_id.max(id.0.saturating_add(1));
|
self.next_id = self.next_id.max(id.0.saturating_add(1));
|
||||||
|
|
||||||
let sub = Subscription { id, pattern, once };
|
let sub = Subscription { id, pattern, once };
|
||||||
|
|
@ -129,24 +134,36 @@ fn matches_glob(pattern: &[u8], text: &[u8]) -> bool {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::matches_pattern;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exact_match() {
|
fn exact_match() {
|
||||||
assert!(matches_pattern("bread.device.dock.connected", "bread.device.dock.connected"));
|
assert!(matches_pattern(
|
||||||
assert!(!matches_pattern("bread.device.dock.connected", "bread.device.dock.disconnected"));
|
"bread.device.dock.connected",
|
||||||
|
"bread.device.dock.connected"
|
||||||
|
));
|
||||||
|
assert!(!matches_pattern(
|
||||||
|
"bread.device.dock.connected",
|
||||||
|
"bread.device.dock.disconnected"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_segment_wildcard() {
|
fn single_segment_wildcard() {
|
||||||
assert!(matches_pattern("bread.device.*", "bread.device.dock.connected"));
|
assert!(matches_pattern(
|
||||||
|
"bread.device.*",
|
||||||
|
"bread.device.dock.connected"
|
||||||
|
));
|
||||||
assert!(matches_pattern("bread.device.*", "bread.device.foo"));
|
assert!(matches_pattern("bread.device.*", "bread.device.foo"));
|
||||||
assert!(!matches_pattern("bread.device.*", "bread.device"));
|
assert!(!matches_pattern("bread.device.*", "bread.device"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recursive_wildcard() {
|
fn recursive_wildcard() {
|
||||||
assert!(matches_pattern("bread.device.**", "bread.device.dock.connected"));
|
assert!(matches_pattern(
|
||||||
|
"bread.device.**",
|
||||||
|
"bread.device.dock.connected"
|
||||||
|
));
|
||||||
assert!(matches_pattern("bread.**", "bread.device.dock.connected"));
|
assert!(matches_pattern("bread.**", "bread.device.dock.connected"));
|
||||||
assert!(matches_pattern("bread.**", "bread"));
|
assert!(matches_pattern("bread.**", "bread"));
|
||||||
}
|
}
|
||||||
|
|
@ -157,4 +174,120 @@ mod tests {
|
||||||
assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10"));
|
assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10"));
|
||||||
assert!(!matches_pattern("bread.monitor.?", "bread.monitor."));
|
assert!(!matches_pattern("bread.monitor.?", "bread.monitor."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn star_does_not_cross_dot_segments() {
|
||||||
|
// `*` matches within a segment only.
|
||||||
|
assert!(matches_pattern(
|
||||||
|
"bread.*.connected",
|
||||||
|
"bread.device.connected"
|
||||||
|
));
|
||||||
|
assert!(!matches_pattern(
|
||||||
|
"bread.*.connected",
|
||||||
|
"bread.device.dock.connected"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_star_matches_zero_or_more_segments() {
|
||||||
|
assert!(matches_pattern("bread.**", "bread.a"));
|
||||||
|
assert!(matches_pattern("bread.**", "bread.a.b.c.d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_pattern_matches_only_empty_text() {
|
||||||
|
assert!(matches_pattern("", ""));
|
||||||
|
assert!(!matches_pattern("", "bread"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_text_only_matches_wildcards() {
|
||||||
|
assert!(matches_pattern("**", ""));
|
||||||
|
assert!(!matches_pattern("bread.*", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SubscriptionTable ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_add_assigns_provided_id_and_finds_match() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
let id = t.add_with_id(SubscriptionId(7), "bread.window.*".into(), false);
|
||||||
|
assert_eq!(id, SubscriptionId(7));
|
||||||
|
|
||||||
|
let matches = t.match_event("bread.window.opened");
|
||||||
|
assert_eq!(matches.len(), 1);
|
||||||
|
assert_eq!(matches[0].id, SubscriptionId(7));
|
||||||
|
assert_eq!(matches[0].pattern, "bread.window.*");
|
||||||
|
assert!(!matches[0].once);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_match_returns_all_matching_subscriptions() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "bread.window.opened".into(), false);
|
||||||
|
t.add_with_id(SubscriptionId(2), "bread.window.*".into(), false);
|
||||||
|
t.add_with_id(SubscriptionId(3), "bread.**".into(), true);
|
||||||
|
t.add_with_id(SubscriptionId(4), "bread.device.*".into(), false);
|
||||||
|
|
||||||
|
let matches = t.match_event("bread.window.opened");
|
||||||
|
let ids: Vec<u64> = matches.iter().map(|s| s.id.0).collect();
|
||||||
|
assert!(ids.contains(&1));
|
||||||
|
assert!(ids.contains(&2));
|
||||||
|
assert!(ids.contains(&3));
|
||||||
|
assert!(!ids.contains(&4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_remove_returns_true_only_for_known_ids() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||||
|
assert!(t.remove(SubscriptionId(1)));
|
||||||
|
// Second remove of the same id is false.
|
||||||
|
assert!(!t.remove(SubscriptionId(1)));
|
||||||
|
// Removing a never-known id is false.
|
||||||
|
assert!(!t.remove(SubscriptionId(999)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_remove_preserves_other_entries_after_swap_remove() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||||
|
t.add_with_id(SubscriptionId(2), "b".into(), false);
|
||||||
|
t.add_with_id(SubscriptionId(3), "c".into(), false);
|
||||||
|
|
||||||
|
// Remove the middle entry — swap_remove will move entry 3 into the slot.
|
||||||
|
assert!(t.remove(SubscriptionId(2)));
|
||||||
|
|
||||||
|
// Subsequent removes still work, proving the by_id index was kept consistent.
|
||||||
|
assert!(t.remove(SubscriptionId(3)));
|
||||||
|
assert!(t.remove(SubscriptionId(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_clear_removes_all() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||||
|
t.add_with_id(SubscriptionId(2), "b".into(), false);
|
||||||
|
t.clear();
|
||||||
|
assert!(t.match_event("a").is_empty());
|
||||||
|
assert!(t.match_event("b").is_empty());
|
||||||
|
// After clear, the ids are reusable.
|
||||||
|
assert!(!t.remove(SubscriptionId(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_match_returns_empty_for_unmatched_event() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "bread.device.*".into(), false);
|
||||||
|
assert!(t.match_event("bread.window.opened").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_once_flag_is_preserved_in_match_result() {
|
||||||
|
let mut t = SubscriptionTable::default();
|
||||||
|
t.add_with_id(SubscriptionId(1), "bread.test".into(), true);
|
||||||
|
let matches = t.match_event("bread.test");
|
||||||
|
assert_eq!(matches.len(), 1);
|
||||||
|
assert!(matches[0].once);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ pub fn spawn_supervised<F, Fut>(
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
mut task_factory: F,
|
mut task_factory: F,
|
||||||
)
|
) where
|
||||||
where
|
|
||||||
F: FnMut() -> Fut + Send + 'static,
|
F: FnMut() -> Fut + Send + 'static,
|
||||||
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
|
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||||
{
|
{
|
||||||
|
|
@ -50,7 +49,11 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
|
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
|
||||||
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure");
|
warn!(
|
||||||
|
adapter = name,
|
||||||
|
delay_ms = wait_ms,
|
||||||
|
"restarting adapter after failure"
|
||||||
|
);
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = sleep(Duration::from_millis(wait_ms)) => {},
|
_ = sleep(Duration::from_millis(wait_ms)) => {},
|
||||||
_ = shutdown_rx.changed() => {
|
_ = shutdown_rx.changed() => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct RuntimeState {
|
pub struct RuntimeState {
|
||||||
pub monitors: Vec<Monitor>,
|
pub monitors: Vec<Monitor>,
|
||||||
pub workspaces: Vec<Workspace>,
|
pub workspaces: Vec<Workspace>,
|
||||||
|
|
@ -16,22 +16,6 @@ pub struct RuntimeState {
|
||||||
pub modules: Vec<ModuleStatus>,
|
pub modules: Vec<ModuleStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
monitors: Vec::new(),
|
|
||||||
workspaces: Vec::new(),
|
|
||||||
active_workspace: None,
|
|
||||||
active_window: None,
|
|
||||||
devices: DeviceTopology::default(),
|
|
||||||
network: NetworkState::default(),
|
|
||||||
power: PowerState::default(),
|
|
||||||
profile: ProfileState::default(),
|
|
||||||
modules: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Monitor {
|
pub struct Monitor {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -100,23 +84,13 @@ pub struct InterfaceState {
|
||||||
pub up: bool,
|
pub up: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PowerState {
|
pub struct PowerState {
|
||||||
pub ac_connected: bool,
|
pub ac_connected: bool,
|
||||||
pub battery_percent: Option<u8>,
|
pub battery_percent: Option<u8>,
|
||||||
pub battery_low: bool,
|
pub battery_low: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PowerState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
ac_connected: false,
|
|
||||||
battery_percent: None,
|
|
||||||
battery_low: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProfileState {
|
pub struct ProfileState {
|
||||||
pub active: String,
|
pub active: String,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ use std::fs;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::time::Instant;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
|
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
|
||||||
|
|
@ -52,6 +52,9 @@ struct IpcResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
// Server::new legitimately requires all 8 fields; a builder pattern here would be
|
||||||
|
// over-engineering for a single-call-site constructor.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
state_handle: StateHandle,
|
state_handle: StateHandle,
|
||||||
|
|
@ -161,7 +164,10 @@ impl Server {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> {
|
async fn handle_request(
|
||||||
|
&self,
|
||||||
|
req: IpcRequest,
|
||||||
|
) -> std::result::Result<(String, Value), (String, String)> {
|
||||||
let id = req.id.clone();
|
let id = req.id.clone();
|
||||||
let result = match req.method.as_str() {
|
let result = match req.method.as_str() {
|
||||||
"ping" => Ok(json!({ "ok": true })),
|
"ping" => Ok(json!({ "ok": true })),
|
||||||
|
|
@ -208,11 +214,7 @@ impl Server {
|
||||||
Ok(profiles)
|
Ok(profiles)
|
||||||
}
|
}
|
||||||
"profile.activate" => {
|
"profile.activate" => {
|
||||||
let Some(name) = req
|
let Some(name) = req.params.get("name").and_then(Value::as_str) else {
|
||||||
.params
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
else {
|
|
||||||
return Err((id, "missing profile name".to_string()));
|
return Err((id, "missing profile name".to_string()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -231,11 +233,7 @@ impl Server {
|
||||||
Ok(json!({ "active": name }))
|
Ok(json!({ "active": name }))
|
||||||
}
|
}
|
||||||
"emit" => {
|
"emit" => {
|
||||||
let Some(event) = req
|
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||||
.params
|
|
||||||
.get("event")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
else {
|
|
||||||
return Err((id, "missing event name".to_string()));
|
return Err((id, "missing event name".to_string()));
|
||||||
};
|
};
|
||||||
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
|
@ -253,7 +251,9 @@ impl Server {
|
||||||
let state = self.state_handle.state_dump().await;
|
let state = self.state_handle.state_dump().await;
|
||||||
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
|
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
|
||||||
let adapters = self.adapter_status.read().await.clone();
|
let adapters = self.adapter_status.read().await.clone();
|
||||||
let subscription_count = self.subscription_count.load(std::sync::atomic::Ordering::Relaxed);
|
let subscription_count = self
|
||||||
|
.subscription_count
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let recent_errors = self.lua_runtime.recent_errors();
|
let recent_errors = self.lua_runtime.recent_errors();
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
|
|
@ -268,14 +268,7 @@ impl Server {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"sync.status" => {
|
"sync.status" => {
|
||||||
let cfg_home = std::env::var("XDG_CONFIG_HOME")
|
let sync_path = bread_sync::config::bread_config_dir().join("sync.toml");
|
||||||
.map(std::path::PathBuf::from)
|
|
||||||
.or_else(|_| {
|
|
||||||
std::env::var("HOME")
|
|
||||||
.map(|h| std::path::PathBuf::from(h).join(".config"))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
|
|
||||||
let sync_path = cfg_home.join("bread").join("sync.toml");
|
|
||||||
match std::fs::read_to_string(&sync_path)
|
match std::fs::read_to_string(&sync_path)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<toml::Value>().ok())
|
.and_then(|s| s.parse::<toml::Value>().ok())
|
||||||
|
|
@ -301,7 +294,11 @@ impl Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"events.replay" => {
|
"events.replay" => {
|
||||||
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
|
let since_ms = req
|
||||||
|
.params
|
||||||
|
.get("since_ms")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0);
|
||||||
let cutoff = now_unix_ms().saturating_sub(since_ms);
|
let cutoff = now_unix_ms().saturating_sub(since_ms);
|
||||||
let replay: Vec<BreadEvent> = self
|
let replay: Vec<BreadEvent> = self
|
||||||
.event_buffer
|
.event_buffer
|
||||||
|
|
@ -412,3 +409,70 @@ fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::matches_filter;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_exact_match() {
|
||||||
|
assert!(matches_filter("bread.window.opened", "bread.window.opened"));
|
||||||
|
assert!(!matches_filter(
|
||||||
|
"bread.window.opened",
|
||||||
|
"bread.window.closed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_dot_star_matches_one_segment_only() {
|
||||||
|
assert!(matches_filter("bread.device.connected", "bread.device.*"));
|
||||||
|
assert!(matches_filter(
|
||||||
|
"bread.device.dock.connected",
|
||||||
|
"bread.device.*"
|
||||||
|
));
|
||||||
|
assert!(!matches_filter("bread.device", "bread.device.*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_dot_double_star_matches_zero_or_more_segments() {
|
||||||
|
// Matches the exact prefix (zero segments after).
|
||||||
|
assert!(matches_filter("bread.device", "bread.device.**"));
|
||||||
|
// And matches deeper paths.
|
||||||
|
assert!(matches_filter(
|
||||||
|
"bread.device.dock.connected",
|
||||||
|
"bread.device.**"
|
||||||
|
));
|
||||||
|
// But not a sibling at the same depth.
|
||||||
|
assert!(!matches_filter(
|
||||||
|
"bread.network.connected",
|
||||||
|
"bread.device.**"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_question_mark_matches_single_char_not_dot() {
|
||||||
|
assert!(matches_filter("bread.x", "bread.?"));
|
||||||
|
assert!(!matches_filter("bread.xy", "bread.?"));
|
||||||
|
assert!(!matches_filter("bread.", "bread.?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_mid_pattern_star_does_not_cross_dots() {
|
||||||
|
// A `*` in the middle of the pattern (not the `.*` suffix shortcut)
|
||||||
|
// matches within a single segment only.
|
||||||
|
assert!(matches_filter("bread.alpha.connected", "bread.*.connected"));
|
||||||
|
assert!(!matches_filter(
|
||||||
|
"bread.alpha.beta.connected",
|
||||||
|
"bread.*.connected"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_dot_star_at_end_acts_as_prefix_match() {
|
||||||
|
// `bread.*` ending the pattern is treated as a prefix match, so
|
||||||
|
// matches everything under `bread.` regardless of depth. This is
|
||||||
|
// consistent with the subscription table's pattern matcher.
|
||||||
|
assert!(matches_filter("bread.alpha", "bread.*"));
|
||||||
|
assert!(matches_filter("bread.alpha.beta", "bread.*"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use bread_shared::{AdapterSource, BreadEvent};
|
use bread_shared::{AdapterSource, BreadEvent};
|
||||||
use libc;
|
|
||||||
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
|
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
|
@ -291,7 +290,9 @@ impl LuaEngine {
|
||||||
let next_sub_id = self.next_sub_id.clone();
|
let next_sub_id = self.next_sub_id.clone();
|
||||||
let state_handle = self.state_handle.clone();
|
let state_handle = self.state_handle.clone();
|
||||||
let current_module = self.current_module.clone();
|
let current_module = self.current_module.clone();
|
||||||
let on_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| {
|
let on_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (pattern, callback): (String, Function)| {
|
||||||
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
let module = current_module
|
let module = current_module
|
||||||
|
|
@ -322,7 +323,9 @@ impl LuaEngine {
|
||||||
let next_sub_id = self.next_sub_id.clone();
|
let next_sub_id = self.next_sub_id.clone();
|
||||||
let state_handle = self.state_handle.clone();
|
let state_handle = self.state_handle.clone();
|
||||||
let current_module = self.current_module.clone();
|
let current_module = self.current_module.clone();
|
||||||
let once_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| {
|
let once_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (pattern, callback): (String, Function)| {
|
||||||
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
let module = current_module
|
let module = current_module
|
||||||
|
|
@ -411,7 +414,9 @@ impl LuaEngine {
|
||||||
bread.set("off", off_fn)?;
|
bread.set("off", off_fn)?;
|
||||||
|
|
||||||
let emit_tx = self.emit_tx.clone();
|
let emit_tx = self.emit_tx.clone();
|
||||||
let emit_fn = self.lua.create_function(move |lua, (event_name, payload): (String, Value)| {
|
let emit_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (event_name, payload): (String, Value)| {
|
||||||
let data = match payload {
|
let data = match payload {
|
||||||
Value::Nil => serde_json::json!({}),
|
Value::Nil => serde_json::json!({}),
|
||||||
other => lua
|
other => lua
|
||||||
|
|
@ -427,9 +432,9 @@ impl LuaEngine {
|
||||||
|
|
||||||
let state_arc = self.state_handle.state_arc();
|
let state_arc = self.state_handle.state_arc();
|
||||||
let state_tbl = self.lua.create_table()?;
|
let state_tbl = self.lua.create_table()?;
|
||||||
let get_fn = self.lua.create_function(move |lua, path: String| {
|
let get_fn = self
|
||||||
state_value_to_lua(lua, &state_arc, &path)
|
.lua
|
||||||
})?;
|
.create_function(move |lua, path: String| state_value_to_lua(lua, &state_arc, &path))?;
|
||||||
state_tbl.set("get", get_fn)?;
|
state_tbl.set("get", get_fn)?;
|
||||||
|
|
||||||
let state_arc = self.state_handle.state_arc();
|
let state_arc = self.state_handle.state_arc();
|
||||||
|
|
@ -439,9 +444,9 @@ impl LuaEngine {
|
||||||
state_tbl.set("monitors", monitors_fn)?;
|
state_tbl.set("monitors", monitors_fn)?;
|
||||||
|
|
||||||
let state_arc = self.state_handle.state_arc();
|
let state_arc = self.state_handle.state_arc();
|
||||||
let active_ws_fn = self
|
let active_ws_fn = self.lua.create_function(move |lua, ()| {
|
||||||
.lua
|
state_value_to_lua(lua, &state_arc, "active_workspace")
|
||||||
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "active_workspace"))?;
|
})?;
|
||||||
state_tbl.set("active_workspace", active_ws_fn)?;
|
state_tbl.set("active_workspace", active_ws_fn)?;
|
||||||
|
|
||||||
let state_arc = self.state_handle.state_arc();
|
let state_arc = self.state_handle.state_arc();
|
||||||
|
|
@ -479,7 +484,9 @@ impl LuaEngine {
|
||||||
let next_sub_id = self.next_sub_id.clone();
|
let next_sub_id = self.next_sub_id.clone();
|
||||||
let state_handle = self.state_handle.clone();
|
let state_handle = self.state_handle.clone();
|
||||||
let current_module = self.current_module.clone();
|
let current_module = self.current_module.clone();
|
||||||
let watch_fn = self.lua.create_function(move |lua, (path, callback): (String, Function)| {
|
let watch_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (path, callback): (String, Function)| {
|
||||||
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
let module = current_module
|
let module = current_module
|
||||||
|
|
@ -555,8 +562,8 @@ impl LuaEngine {
|
||||||
let default_urgency = self.notifications_config.default_urgency.clone();
|
let default_urgency = self.notifications_config.default_urgency.clone();
|
||||||
let default_timeout = self.notifications_config.default_timeout_ms;
|
let default_timeout = self.notifications_config.default_timeout_ms;
|
||||||
let emit_tx = self.emit_tx.clone();
|
let emit_tx = self.emit_tx.clone();
|
||||||
let notify_fn = self
|
let notify_fn =
|
||||||
.lua
|
self.lua
|
||||||
.create_function(move |_lua, (message, opts): (String, Option<Table>)| {
|
.create_function(move |_lua, (message, opts): (String, Option<Table>)| {
|
||||||
let title: String = opts
|
let title: String = opts
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -609,7 +616,9 @@ impl LuaEngine {
|
||||||
let timers = self.timers.clone();
|
let timers = self.timers.clone();
|
||||||
let next_timer_id = self.next_timer_id.clone();
|
let next_timer_id = self.next_timer_id.clone();
|
||||||
let lua_tx = self.lua_tx.clone();
|
let lua_tx = self.lua_tx.clone();
|
||||||
let after_fn = self.lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
|
let after_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
|
||||||
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
|
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
||||||
|
|
@ -642,7 +651,9 @@ impl LuaEngine {
|
||||||
let timers = self.timers.clone();
|
let timers = self.timers.clone();
|
||||||
let next_timer_id = self.next_timer_id.clone();
|
let next_timer_id = self.next_timer_id.clone();
|
||||||
let lua_tx = self.lua_tx.clone();
|
let lua_tx = self.lua_tx.clone();
|
||||||
let every_fn = self.lua.create_function(move |lua, (interval_ms, callback): (u64, Function)| {
|
let every_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |lua, (interval_ms, callback): (u64, Function)| {
|
||||||
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
|
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
||||||
|
|
@ -694,14 +705,18 @@ impl LuaEngine {
|
||||||
bread.set("cancel", cancel_fn)?;
|
bread.set("cancel", cancel_fn)?;
|
||||||
|
|
||||||
let hyprland_tbl = self.lua.create_table()?;
|
let hyprland_tbl = self.lua.create_table()?;
|
||||||
let dispatch_fn = self.lua.create_function(move |_lua, (cmd, args): (String, String)| {
|
let dispatch_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |_lua, (cmd, args): (String, String)| {
|
||||||
let resp = hyprland_request(&format!("dispatch {cmd} {args}"))
|
let resp = hyprland_request(&format!("dispatch {cmd} {args}"))
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
})?;
|
})?;
|
||||||
hyprland_tbl.set("dispatch", dispatch_fn)?;
|
hyprland_tbl.set("dispatch", dispatch_fn)?;
|
||||||
|
|
||||||
let keyword_fn = self.lua.create_function(move |_lua, (key, value): (String, String)| {
|
let keyword_fn =
|
||||||
|
self.lua
|
||||||
|
.create_function(move |_lua, (key, value): (String, String)| {
|
||||||
let resp = hyprland_request(&format!("keyword {key} {value}"))
|
let resp = hyprland_request(&format!("keyword {key} {value}"))
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
|
|
@ -718,38 +733,38 @@ impl LuaEngine {
|
||||||
let active_window_fn = self.lua.create_function(move |lua, ()| {
|
let active_window_fn = self.lua.create_function(move |lua, ()| {
|
||||||
let resp = hyprland_request("j/activewindow")
|
let resp = hyprland_request("j/activewindow")
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
let json: JsonValue = serde_json::from_str(&resp)
|
let json: JsonValue =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
lua.to_value(&json)
|
lua.to_value(&json)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
.map_err(|e| LuaError::external(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
hyprland_tbl.set("active_window", active_window_fn)?;
|
hyprland_tbl.set("active_window", active_window_fn)?;
|
||||||
|
|
||||||
let monitors_fn = self.lua.create_function(move |lua, ()| {
|
let monitors_fn = self.lua.create_function(move |lua, ()| {
|
||||||
let resp = hyprland_request("j/monitors")
|
let resp =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
hyprland_request("j/monitors").map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
let json: JsonValue = serde_json::from_str(&resp)
|
let json: JsonValue =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
lua.to_value(&json)
|
lua.to_value(&json)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
.map_err(|e| LuaError::external(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
hyprland_tbl.set("monitors", monitors_fn)?;
|
hyprland_tbl.set("monitors", monitors_fn)?;
|
||||||
|
|
||||||
let workspaces_fn = self.lua.create_function(move |lua, ()| {
|
let workspaces_fn = self.lua.create_function(move |lua, ()| {
|
||||||
let resp = hyprland_request("j/workspaces")
|
let resp =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
hyprland_request("j/workspaces").map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
let json: JsonValue = serde_json::from_str(&resp)
|
let json: JsonValue =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
lua.to_value(&json)
|
lua.to_value(&json)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
.map_err(|e| LuaError::external(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
hyprland_tbl.set("workspaces", workspaces_fn)?;
|
hyprland_tbl.set("workspaces", workspaces_fn)?;
|
||||||
|
|
||||||
let clients_fn = self.lua.create_function(move |lua, ()| {
|
let clients_fn = self.lua.create_function(move |lua, ()| {
|
||||||
let resp = hyprland_request("j/clients")
|
let resp =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
hyprland_request("j/clients").map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
let json: JsonValue = serde_json::from_str(&resp)
|
let json: JsonValue =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
lua.to_value(&json)
|
lua.to_value(&json)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
.map_err(|e| LuaError::external(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -759,8 +774,8 @@ impl LuaEngine {
|
||||||
let next_sub_id = self.next_sub_id.clone();
|
let next_sub_id = self.next_sub_id.clone();
|
||||||
let state_handle = self.state_handle.clone();
|
let state_handle = self.state_handle.clone();
|
||||||
let current_module = self.current_module.clone();
|
let current_module = self.current_module.clone();
|
||||||
let on_raw_fn = self
|
let on_raw_fn =
|
||||||
.lua
|
self.lua
|
||||||
.create_function(move |lua, (event, callback): (String, Function)| {
|
.create_function(move |lua, (event, callback): (String, Function)| {
|
||||||
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
|
||||||
let key = lua.create_registry_value(callback)?;
|
let key = lua.create_registry_value(callback)?;
|
||||||
|
|
@ -800,7 +815,9 @@ impl LuaEngine {
|
||||||
.map_err(|_| LuaError::external("module context lock poisoned"))?
|
.map_err(|_| LuaError::external("module context lock poisoned"))?
|
||||||
.clone();
|
.clone();
|
||||||
if expected.as_deref() != Some(&name) {
|
if expected.as_deref() != Some(&name) {
|
||||||
return Err(LuaError::external("module name does not match current load"));
|
return Err(LuaError::external(
|
||||||
|
"module name does not match current load",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let decl = module_decls
|
let decl = module_decls
|
||||||
|
|
@ -834,7 +851,7 @@ impl LuaEngine {
|
||||||
let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| {
|
let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| {
|
||||||
let json = lua
|
let json = lua
|
||||||
.from_value::<JsonValue>(value)
|
.from_value::<JsonValue>(value)
|
||||||
.unwrap_or_else(|_| JsonValue::Null);
|
.unwrap_or(JsonValue::Null);
|
||||||
module_store_set(&state_arc_set, &module_name, key, json);
|
module_store_set(&state_arc_set, &module_name, key, json);
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -845,10 +862,7 @@ impl LuaEngine {
|
||||||
modules
|
modules
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| LuaError::external("module registry lock poisoned"))?
|
.map_err(|_| LuaError::external("module registry lock poisoned"))?
|
||||||
.insert(
|
.insert(decl.name.clone(), ModuleInfo { table_key: key });
|
||||||
decl.name.clone(),
|
|
||||||
ModuleInfo { table_key: key },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register in package.loaded so require("bread.devices") etc. works
|
// Register in package.loaded so require("bread.devices") etc. works
|
||||||
let package: Table = lua.globals().get("package")?;
|
let package: Table = lua.globals().get("package")?;
|
||||||
|
|
@ -862,9 +876,9 @@ impl LuaEngine {
|
||||||
// bread.machine — machine name and tags from sync.toml
|
// bread.machine — machine name and tags from sync.toml
|
||||||
let machine_tbl = self.lua.create_table()?;
|
let machine_tbl = self.lua.create_table()?;
|
||||||
|
|
||||||
let name_fn = self.lua.create_function(|_lua, ()| {
|
let name_fn = self
|
||||||
Ok(lua_machine_name())
|
.lua
|
||||||
})?;
|
.create_function(|_lua, ()| Ok(lua_machine_name()))?;
|
||||||
machine_tbl.set("name", name_fn)?;
|
machine_tbl.set("name", name_fn)?;
|
||||||
|
|
||||||
let tags_fn = self.lua.create_function(|lua, ()| {
|
let tags_fn = self.lua.create_function(|lua, ()| {
|
||||||
|
|
@ -877,9 +891,9 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
machine_tbl.set("tags", tags_fn)?;
|
machine_tbl.set("tags", tags_fn)?;
|
||||||
|
|
||||||
let has_tag_fn = self.lua.create_function(|_lua, tag: String| {
|
let has_tag_fn = self
|
||||||
Ok(lua_machine_tags().contains(&tag))
|
.lua
|
||||||
})?;
|
.create_function(|_lua, tag: String| Ok(lua_machine_tags().contains(&tag)))?;
|
||||||
machine_tbl.set("has_tag", has_tag_fn)?;
|
machine_tbl.set("has_tag", has_tag_fn)?;
|
||||||
|
|
||||||
bread.set("machine", machine_tbl)?;
|
bread.set("machine", machine_tbl)?;
|
||||||
|
|
@ -887,14 +901,15 @@ impl LuaEngine {
|
||||||
// bread.fs — file system helpers
|
// bread.fs — file system helpers
|
||||||
let fs_tbl = self.lua.create_table()?;
|
let fs_tbl = self.lua.create_table()?;
|
||||||
|
|
||||||
let write_fn = self.lua.create_function(|_lua, (path, content): (String, String)| {
|
let write_fn = self
|
||||||
|
.lua
|
||||||
|
.create_function(|_lua, (path, content): (String, String)| {
|
||||||
let expanded = lua_expand_path(&path);
|
let expanded = lua_expand_path(&path);
|
||||||
if let Some(parent) = expanded.parent() {
|
if let Some(parent) = expanded.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
}
|
}
|
||||||
std::fs::write(&expanded, content)
|
std::fs::write(&expanded, content).map_err(|e| LuaError::external(e.to_string()))
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
|
||||||
})?;
|
})?;
|
||||||
fs_tbl.set("write", write_fn)?;
|
fs_tbl.set("write", write_fn)?;
|
||||||
|
|
||||||
|
|
@ -907,9 +922,9 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
fs_tbl.set("read", read_fn)?;
|
fs_tbl.set("read", read_fn)?;
|
||||||
|
|
||||||
let exists_fn = self.lua.create_function(|_lua, path: String| {
|
let exists_fn = self
|
||||||
Ok(lua_expand_path(&path).exists())
|
.lua
|
||||||
})?;
|
.create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?;
|
||||||
fs_tbl.set("exists", exists_fn)?;
|
fs_tbl.set("exists", exists_fn)?;
|
||||||
|
|
||||||
let expand_fn = self.lua.create_function(|_lua, path: String| {
|
let expand_fn = self.lua.create_function(|_lua, path: String| {
|
||||||
|
|
@ -1025,18 +1040,16 @@ impl LuaEngine {
|
||||||
let mut files = list_lua_files(&self.module_path)?;
|
let mut files = list_lua_files(&self.module_path)?;
|
||||||
files.sort();
|
files.sort();
|
||||||
|
|
||||||
let disabled: HashSet<String> = self
|
let disabled: HashSet<String> = self.modules_config.disable.iter().cloned().collect();
|
||||||
.modules_config
|
|
||||||
.disable
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut decls = Vec::new();
|
let mut decls = Vec::new();
|
||||||
if self.modules_config.builtin {
|
if self.modules_config.builtin {
|
||||||
decls.extend(builtin_module_decls(&disabled));
|
decls.extend(builtin_module_decls(&disabled));
|
||||||
}
|
}
|
||||||
for path in files.into_iter().filter(|p| !is_lib_path(&self.module_path, p)) {
|
for path in files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| !is_lib_path(&self.module_path, p))
|
||||||
|
{
|
||||||
match self.scan_module_decl(&path) {
|
match self.scan_module_decl(&path) {
|
||||||
Ok(decl) => decls.push(decl),
|
Ok(decl) => decls.push(decl),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -1130,7 +1143,10 @@ impl LuaEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
let src = fs::read_to_string(path)?;
|
let src = fs::read_to_string(path)?;
|
||||||
self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
|
self.lua
|
||||||
|
.load(&src)
|
||||||
|
.set_name(path.to_string_lossy().as_ref())
|
||||||
|
.exec()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1371,7 +1387,9 @@ impl LuaEngine {
|
||||||
//
|
//
|
||||||
// Each accepts any Lua value and coerces it to a string via tostring()
|
// Each accepts any Lua value and coerces it to a string via tostring()
|
||||||
// so callers can do bread.log(some_table) without a crash.
|
// so callers can do bread.log(some_table) without a crash.
|
||||||
self.lua.load(r#"
|
self.lua
|
||||||
|
.load(
|
||||||
|
r#"
|
||||||
local _bread = bread
|
local _bread = bread
|
||||||
|
|
||||||
local function stringify(v)
|
local function stringify(v)
|
||||||
|
|
@ -1392,7 +1410,9 @@ impl LuaEngine {
|
||||||
function _bread.error(msg)
|
function _bread.error(msg)
|
||||||
_bread.__log_error(stringify(msg))
|
_bread.__log_error(stringify(msg))
|
||||||
end
|
end
|
||||||
"#).exec()?;
|
"#,
|
||||||
|
)
|
||||||
|
.exec()?;
|
||||||
|
|
||||||
// Register the raw Rust-backed log functions that the Lua wrappers call.
|
// Register the raw Rust-backed log functions that the Lua wrappers call.
|
||||||
let globals = self.lua.globals();
|
let globals = self.lua.globals();
|
||||||
|
|
@ -1429,7 +1449,9 @@ impl LuaEngine {
|
||||||
//
|
//
|
||||||
// Because the Lua runtime is single-threaded, we implement this in
|
// Because the Lua runtime is single-threaded, we implement this in
|
||||||
// pure Lua using bread.cancel / bread.after.
|
// pure Lua using bread.cancel / bread.after.
|
||||||
self.lua.load(r#"
|
self.lua
|
||||||
|
.load(
|
||||||
|
r#"
|
||||||
function bread.debounce(delay_ms, fn)
|
function bread.debounce(delay_ms, fn)
|
||||||
local timer_id = nil
|
local timer_id = nil
|
||||||
return function(...)
|
return function(...)
|
||||||
|
|
@ -1444,7 +1466,9 @@ impl LuaEngine {
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
"#).exec()?;
|
"#,
|
||||||
|
)
|
||||||
|
.exec()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1476,7 +1500,8 @@ impl LuaEngine {
|
||||||
let bread = lua.create_table()?;
|
let bread = lua.create_table()?;
|
||||||
bread.set("module", module_fn)?;
|
bread.set("module", module_fn)?;
|
||||||
lua.globals().set("bread", bread)?;
|
lua.globals().set("bread", bread)?;
|
||||||
lua.load(r#"
|
lua.load(
|
||||||
|
r#"
|
||||||
local _noop = function(...) end
|
local _noop = function(...) end
|
||||||
local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop }
|
local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop }
|
||||||
local _noop_tbl = setmetatable({}, _noop_tbl_mt)
|
local _noop_tbl = setmetatable({}, _noop_tbl_mt)
|
||||||
|
|
@ -1486,10 +1511,15 @@ impl LuaEngine {
|
||||||
return _noop_tbl
|
return _noop_tbl
|
||||||
end
|
end
|
||||||
})
|
})
|
||||||
"#).exec()?;
|
"#,
|
||||||
|
)
|
||||||
|
.exec()?;
|
||||||
|
|
||||||
let src = fs::read_to_string(path)?;
|
let src = fs::read_to_string(path)?;
|
||||||
let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec();
|
let result = lua
|
||||||
|
.load(&src)
|
||||||
|
.set_name(path.to_string_lossy().as_ref())
|
||||||
|
.exec();
|
||||||
// bread.module() throws MODULE_DECL_ABORT to abort scanning early.
|
// bread.module() throws MODULE_DECL_ABORT to abort scanning early.
|
||||||
// mlua may wrap the error in CallbackError, so match on string content.
|
// mlua may wrap the error in CallbackError, so match on string content.
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
|
|
@ -1515,8 +1545,7 @@ impl LuaEngine {
|
||||||
return Ok(Value::Nil);
|
return Ok(Value::Nil);
|
||||||
}
|
}
|
||||||
|
|
||||||
let src = fs::read_to_string(&path)
|
let src = fs::read_to_string(&path).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
|
||||||
let func = lua
|
let func = lua
|
||||||
.load(&src)
|
.load(&src)
|
||||||
.set_name(path.to_string_lossy().as_ref())
|
.set_name(path.to_string_lossy().as_ref())
|
||||||
|
|
@ -1529,7 +1558,8 @@ impl LuaEngine {
|
||||||
let bread: Table = globals.get("bread")?;
|
let bread: Table = globals.get("bread")?;
|
||||||
bread.set("__require_loader", loader)?;
|
bread.set("__require_loader", loader)?;
|
||||||
|
|
||||||
self.lua.load(
|
self.lua
|
||||||
|
.load(
|
||||||
r#"
|
r#"
|
||||||
local searchers = package.searchers or package.loaders
|
local searchers = package.searchers or package.loaders
|
||||||
if searchers then
|
if searchers then
|
||||||
|
|
@ -1664,10 +1694,7 @@ fn order_module_decls(decls: Vec<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String,
|
||||||
|
|
||||||
fn module_name_from_path(module_root: &Path, path: &Path) -> String {
|
fn module_name_from_path(module_root: &Path, path: &Path) -> String {
|
||||||
let rel = path.strip_prefix(module_root).unwrap_or(path);
|
let rel = path.strip_prefix(module_root).unwrap_or(path);
|
||||||
let mut name = rel
|
let mut name = rel.with_extension("").to_string_lossy().replace('/', ".");
|
||||||
.with_extension("")
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace('/', ".");
|
|
||||||
if name.starts_with('.') {
|
if name.starts_with('.') {
|
||||||
name.remove(0);
|
name.remove(0);
|
||||||
}
|
}
|
||||||
|
|
@ -1697,8 +1724,8 @@ fn state_value_to_lua<'lua>(
|
||||||
}
|
}
|
||||||
std::hint::spin_loop();
|
std::hint::spin_loop();
|
||||||
};
|
};
|
||||||
let mut value = serde_json::to_value(&*snapshot)
|
let mut value =
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return lua
|
return lua
|
||||||
.to_value(&value)
|
.to_value(&value)
|
||||||
|
|
@ -1714,7 +1741,11 @@ fn state_value_to_lua<'lua>(
|
||||||
.map_err(|e| LuaError::external(e.to_string()))
|
.map_err(|e| LuaError::external(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn module_store_get(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: &str) -> Option<JsonValue> {
|
fn module_store_get(
|
||||||
|
state_arc: &Arc<RwLock<RuntimeState>>,
|
||||||
|
module: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> Option<JsonValue> {
|
||||||
let guard = loop {
|
let guard = loop {
|
||||||
if let Ok(g) = state_arc.try_read() {
|
if let Ok(g) = state_arc.try_read() {
|
||||||
break g;
|
break g;
|
||||||
|
|
@ -1725,7 +1756,12 @@ fn module_store_get(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: &s
|
||||||
entry.store.get(key).cloned()
|
entry.store.get(key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: String, value: JsonValue) {
|
fn module_store_set(
|
||||||
|
state_arc: &Arc<RwLock<RuntimeState>>,
|
||||||
|
module: &str,
|
||||||
|
key: String,
|
||||||
|
value: JsonValue,
|
||||||
|
) {
|
||||||
let mut guard = loop {
|
let mut guard = loop {
|
||||||
if let Ok(g) = state_arc.try_write() {
|
if let Ok(g) = state_arc.try_write() {
|
||||||
break g;
|
break g;
|
||||||
|
|
@ -1824,9 +1860,7 @@ fn lua_machine_tags() -> Vec<String> {
|
||||||
fn read_sync_toml() -> anyhow::Result<toml::Value> {
|
fn read_sync_toml() -> anyhow::Result<toml::Value> {
|
||||||
let config_dir = std::env::var("XDG_CONFIG_HOME")
|
let config_dir = std::env::var("XDG_CONFIG_HOME")
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.or_else(|_| {
|
.or_else(|_| std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||||
std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
|
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
|
||||||
let path = config_dir.join("bread").join("sync.toml");
|
let path = config_dir.join("bread").join("sync.toml");
|
||||||
let raw = std::fs::read_to_string(path)?;
|
let raw = std::fs::read_to_string(path)?;
|
||||||
|
|
@ -2102,7 +2136,10 @@ fn hyprland_request_socket() -> Result<PathBuf> {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
match sockets.len() {
|
match sockets.len() {
|
||||||
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
|
0 => Err(anyhow!(
|
||||||
|
"no Hyprland instance found in {}",
|
||||||
|
hypr_dir.display()
|
||||||
|
)),
|
||||||
1 => Ok(sockets.remove(0)),
|
1 => Ok(sockets.remove(0)),
|
||||||
_ => Ok(sockets.remove(0)),
|
_ => Ok(sockets.remove(0)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ mod ipc;
|
||||||
mod lua;
|
mod lua;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||||
|
|
@ -36,9 +36,10 @@ async fn main() -> Result<()> {
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
let subscription_count = Arc::new(AtomicU64::new(0));
|
let subscription_count = Arc::new(AtomicU64::new(0));
|
||||||
let state_handle = StateHandle::new(state.clone(), state_cmd_tx, subscription_count.clone());
|
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
|
||||||
|
|
||||||
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
let lua_runtime =
|
||||||
|
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
||||||
let lua_tx = lua_runtime.sender();
|
let lua_tx = lua_runtime.sender();
|
||||||
|
|
||||||
tokio::spawn(run_state_engine(
|
tokio::spawn(run_state_engine(
|
||||||
|
|
@ -144,7 +145,8 @@ async fn wait_for_shutdown() {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use tokio::signal::unix::{signal, SignalKind};
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
let mut sigterm =
|
||||||
|
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = ctrl_c => {},
|
_ = ctrl_c => {},
|
||||||
_ = sigterm.recv() => {},
|
_ = sigterm.recv() => {},
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,291 @@ async fn ping_and_state_dump_work() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_method_returns_error() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("not.a.real.method", json!({})).await;
|
||||||
|
assert!(result.is_err(), "expected error for unknown method");
|
||||||
|
let msg = result.err().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("unknown method"),
|
||||||
|
"expected 'unknown method', got: {msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_activate_updates_state() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness
|
||||||
|
.send_request("profile.activate", json!({"name": "battery"}))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
result.get("active").and_then(Value::as_str),
|
||||||
|
Some("battery")
|
||||||
|
);
|
||||||
|
|
||||||
|
let dump = harness.send_request("state.dump", json!({})).await?;
|
||||||
|
assert_eq!(
|
||||||
|
dump.get("profile")
|
||||||
|
.and_then(|v| v.get("active"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("battery")
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_activate_without_name_errors() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("profile.activate", json!({})).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let msg = result.err().unwrap().to_string();
|
||||||
|
assert!(msg.contains("missing profile name"), "got: {msg}");
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_without_event_errors() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("emit", json!({})).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn state_get_returns_specific_subtree() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let modules = harness
|
||||||
|
.send_request("state.get", json!({"key": "modules"}))
|
||||||
|
.await?;
|
||||||
|
assert!(modules.is_array(), "expected modules to be an array");
|
||||||
|
|
||||||
|
let active = harness
|
||||||
|
.send_request("state.get", json!({"key": "profile.active"}))
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
active.as_str().is_some(),
|
||||||
|
"expected profile.active to be a string, got: {active:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn state_get_missing_key_returns_error() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness
|
||||||
|
.send_request("state.get", json!({"key": "does.not.exist"}))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn modules_list_returns_array() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("modules.list", json!({})).await?;
|
||||||
|
assert!(result.is_array());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn modules_reload_succeeds() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("modules.reload", json!({})).await?;
|
||||||
|
assert_eq!(result.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
|
assert!(result.get("duration_ms").is_some());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_status_uninitialized_when_no_config() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("sync.status", json!({})).await?;
|
||||||
|
assert_eq!(
|
||||||
|
result.get("initialized").and_then(Value::as_bool),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_status_reports_initialized_with_config() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness.send_request("sync.status", json!({})).await?;
|
||||||
|
assert_eq!(
|
||||||
|
result.get("initialized").and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get("machine").and_then(Value::as_str),
|
||||||
|
Some("myhost")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get("remote").and_then(Value::as_str),
|
||||||
|
Some("git@example.com:user/repo.git")
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_replay_returns_buffered_events() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// Emit a couple of events.
|
||||||
|
harness
|
||||||
|
.send_request("emit", json!({"event": "bread.replay.a", "data": {}}))
|
||||||
|
.await?;
|
||||||
|
harness
|
||||||
|
.send_request("emit", json!({"event": "bread.replay.b", "data": {}}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Small delay so the events make it into the buffer.
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let result = harness
|
||||||
|
.send_request("events.replay", json!({"since_ms": 10_000}))
|
||||||
|
.await?;
|
||||||
|
let arr = result.as_array().expect("replay result should be array");
|
||||||
|
let names: Vec<&str> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.get("event").and_then(Value::as_str))
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&"bread.replay.a"));
|
||||||
|
assert!(names.contains(&"bread.replay.b"));
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn event_stream_filter_excludes_non_matching_events() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let stream = UnixStream::connect(harness.socket_path()).await?;
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let subscribe = json!({
|
||||||
|
"id": "sub-x",
|
||||||
|
"method": "events.subscribe",
|
||||||
|
"params": {
|
||||||
|
"filter": "bread.match.*"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
write_half
|
||||||
|
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(read_half).lines();
|
||||||
|
// Consume the ack line.
|
||||||
|
reader.next_line().await?;
|
||||||
|
|
||||||
|
// Emit one matching and one non-matching event.
|
||||||
|
harness
|
||||||
|
.send_request("emit", json!({"event": "bread.nomatch.x", "data": {}}))
|
||||||
|
.await?;
|
||||||
|
harness
|
||||||
|
.send_request("emit", json!({"event": "bread.match.yes", "data": {}}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut matched = false;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let Some(line) = reader.next_line().await? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let event: Value = serde_json::from_str(&line)?;
|
||||||
|
let name = event.get("event").and_then(Value::as_str).unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
!name.starts_with("bread.nomatch"),
|
||||||
|
"filter let through non-matching event: {name}"
|
||||||
|
);
|
||||||
|
if name == "bread.match.yes" {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(matched, "did not receive matching event through filter");
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_concurrent_clients_each_get_response() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
let socket = harness.socket_path().to_path_buf();
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..8 {
|
||||||
|
let socket = socket.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let stream = UnixStream::connect(&socket).await?;
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let req = json!({"id": i.to_string(), "method": "ping", "params": {}});
|
||||||
|
write_half
|
||||||
|
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
|
||||||
|
.await?;
|
||||||
|
let mut lines = BufReader::new(read_half).lines();
|
||||||
|
let line = lines.next_line().await?.ok_or_else(|| anyhow!("eof"))?;
|
||||||
|
let parsed: Value = serde_json::from_str(&line)?;
|
||||||
|
assert_eq!(
|
||||||
|
parsed.get("id").and_then(Value::as_str),
|
||||||
|
Some(i.to_string().as_str())
|
||||||
|
);
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
h.await??;
|
||||||
|
}
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn events_stream_receives_emitted_events() -> Result<()> {
|
async fn events_stream_receives_emitted_events() -> Result<()> {
|
||||||
let harness = TestHarness::spawn()?;
|
let harness = TestHarness::spawn()?;
|
||||||
|
|
@ -100,6 +385,14 @@ struct TestHarness {
|
||||||
|
|
||||||
impl TestHarness {
|
impl TestHarness {
|
||||||
fn spawn() -> Result<Self> {
|
fn spawn() -> Result<Self> {
|
||||||
|
Self::spawn_inner(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result<Self> {
|
||||||
|
Self::spawn_inner(Some((machine.to_string(), remote_url.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_inner(sync_config: Option<(String, String)>) -> 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");
|
||||||
|
|
@ -140,6 +433,21 @@ enabled = false
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
if let Some((machine, remote_url)) = sync_config {
|
||||||
|
let sync_toml = format!(
|
||||||
|
r#"
|
||||||
|
[remote]
|
||||||
|
url = "{remote_url}"
|
||||||
|
branch = "main"
|
||||||
|
|
||||||
|
[machine]
|
||||||
|
name = "{machine}"
|
||||||
|
tags = []
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
fs::write(bread_cfg.join("sync.toml"), sync_toml)?;
|
||||||
|
}
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
||||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
# Maintainer: Your Name <you@example.com>
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
pkgname=bread
|
pkgname=bread
|
||||||
pkgver=0.1.0
|
pkgver=1.0.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Bread - event normalizer and automation runtime"
|
pkgdesc="A reactive automation fabric for Linux desktops"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Breadway/bread"
|
url="https://github.com/Breadway/bread"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('glibc')
|
depends=('glibc' 'libgit2')
|
||||||
makedepends=('rust')
|
optdepends=(
|
||||||
|
'libnotify: desktop notifications via bread.notify()'
|
||||||
|
'upower: D-Bus battery events (sysfs polling used otherwise)'
|
||||||
|
'git: bread sync push/pull operations'
|
||||||
|
)
|
||||||
|
makedepends=('rust' 'cargo')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
source=("${pkgname}-${pkgver}.tar.gz")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
|
@ -17,8 +22,15 @@ build() {
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
cargo test --release --locked --workspace
|
||||||
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
|
||||||
install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
|
install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
|
||||||
install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service"
|
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
|
||||||
|
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,21 @@ fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── verify ─────────────────────────────────────────────────────────────────────
|
# ── verify ─────────────────────────────────────────────────────────────────────
|
||||||
sleep 0.5
|
# Wait up to ~5s for the daemon to come up. Polling beats a fixed sleep
|
||||||
|
# because a freshly enabled systemd unit can take a variable amount of time
|
||||||
|
# to fork, bind the socket, and become ready.
|
||||||
|
ready=0
|
||||||
|
for _ in $(seq 1 25); do
|
||||||
if "$BIN_DIR/bread" ping &>/dev/null; then
|
if "$BIN_DIR/bread" ping &>/dev/null; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$ready" -eq 1 ]]; then
|
||||||
"$BIN_DIR/bread" doctor
|
"$BIN_DIR/bread" doctor
|
||||||
else
|
else
|
||||||
echo "warning: daemon did not respond to ping"
|
echo "warning: daemon did not respond to ping within 5s"
|
||||||
echo " check: journalctl --user -u breadd -n 20"
|
echo " check: journalctl --user -u breadd -n 20"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue