Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers
This commit is contained in:
parent
89c5849539
commit
1208c5d1b7
29 changed files with 4098 additions and 339 deletions
|
|
@ -57,6 +57,14 @@ pub struct AdaptersConfig {
|
|||
pub network: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub bluetooth: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub filesystem: RootsConfig,
|
||||
#[serde(default)]
|
||||
pub systemd: SystemdConfig,
|
||||
#[serde(default)]
|
||||
pub podman: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub git: RootsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -81,6 +89,30 @@ pub struct PowerConfig {
|
|||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Shared shape for adapters scoped to a list of project-root glob patterns
|
||||
/// (e.g. `~/Projects/*`) — used by both the filesystem and git adapters.
|
||||
/// `roots` defaults to empty: these adapters do nothing until the user opts
|
||||
/// in with actual paths, since there's no universally-safe default directory
|
||||
/// to watch.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RootsConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub roots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SystemdConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Allowlist of `systemd --user` unit names to watch. Empty by default —
|
||||
/// subscribing to every user unit's transitions is noisy, so nothing is
|
||||
/// watched until the user names specific units.
|
||||
#[serde(default)]
|
||||
pub units: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EventsConfig {
|
||||
#[serde(default = "default_dedup_window")]
|
||||
|
|
@ -150,6 +182,24 @@ impl Default for PowerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for RootsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
roots: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemdConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
units: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -206,7 +256,20 @@ fn config_path() -> PathBuf {
|
|||
expand_home("~/.config/bread/breadd.toml")
|
||||
}
|
||||
|
||||
/// Expands a leading `~/`. `~/.config/...` paths specifically prefer
|
||||
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
|
||||
/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point`
|
||||
/// / `lua.module_path` values (`"~/.config/bread/init.lua"` and
|
||||
/// `"~/.config/bread/modules"`) would silently ignore `XDG_CONFIG_HOME` even
|
||||
/// though the config file that sets them was found via that same variable,
|
||||
/// which is exactly the kind of inconsistency that made init.lua/module
|
||||
/// loading silently no-op for a XDG_CONFIG_HOME-only test setup.
|
||||
fn expand_home(input: &str) -> PathBuf {
|
||||
if let Some(stripped) = input.strip_prefix("~/.config/") {
|
||||
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg_config).join(stripped);
|
||||
}
|
||||
}
|
||||
if let Some(stripped) = input.strip_prefix("~/") {
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return Path::new(&home).join(stripped);
|
||||
|
|
@ -451,8 +514,9 @@ log_level = "trace"
|
|||
|
||||
#[test]
|
||||
fn lua_entry_point_and_module_path_expand_tilde() {
|
||||
let _g = EnvGuard::new(&["HOME"]);
|
||||
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.lua_entry_point(),
|
||||
|
|
@ -464,6 +528,27 @@ log_level = "trace"
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_and_module_path_prefer_xdg_config_home_when_set() {
|
||||
// config_path() (finding breadd.toml itself) already prefers
|
||||
// XDG_CONFIG_HOME over HOME; the `~/.config/...` defaults for
|
||||
// entry_point/module_path must resolve consistently with it, or a
|
||||
// XDG_CONFIG_HOME-only setup (no matching $HOME/.config layout)
|
||||
// silently fails to find its own init.lua/modules.
|
||||
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.lua_entry_point(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/init.lua")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.lua_module_path(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/modules")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_returns_absolute_path_unchanged() {
|
||||
let mut cfg = Config::default();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
|
|
@ -26,16 +26,23 @@ impl EventNormalizer {
|
|||
}
|
||||
|
||||
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut out = match raw.source {
|
||||
let mut out = match &raw.source {
|
||||
AdapterSource::Udev => self.normalize_udev(raw),
|
||||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||
AdapterSource::Power => self.normalize_power(raw),
|
||||
AdapterSource::Network => self.normalize_network(raw),
|
||||
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
|
||||
AdapterSource::Terminal => self.normalize_terminal(raw),
|
||||
AdapterSource::Git => self.normalize_git(raw),
|
||||
AdapterSource::Filesystem => self.normalize_filesystem(raw),
|
||||
AdapterSource::Systemd => self.normalize_systemd(raw),
|
||||
AdapterSource::Podman => self.normalize_podman(raw),
|
||||
AdapterSource::Remote => self.normalize_remote(raw),
|
||||
AdapterSource::App(_) => self.normalize_app(raw),
|
||||
AdapterSource::System => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
};
|
||||
|
|
@ -418,7 +425,105 @@ impl EventNormalizer {
|
|||
}]
|
||||
}
|
||||
|
||||
// Adapter contracts: each of these adapters emits `RawEvent.kind` already
|
||||
// namespaced for its family (e.g. filesystem emits "file.changed",
|
||||
// "detected", "build_artifact.created"), so normalization here is just a
|
||||
// `bread.<family>.` prefix — except systemd (`unit.*` -> `service.*`) and
|
||||
// podman's health-status rename, which need a small rewrite.
|
||||
fn normalize_terminal(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.terminal.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_remote(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.remote.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_git(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.git.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_filesystem(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.project.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_systemd(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
// Adapter emits "unit.started"/"unit.stopped"/"unit.failed"; the public
|
||||
// namespace is `service.*`, not `unit.*`.
|
||||
let suffix = raw.kind.strip_prefix("unit.").unwrap_or(raw.kind.as_str());
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.service.{suffix}"),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_podman(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
// Adapter emits "container.started"/"container.stopped"/"container.health_status";
|
||||
// the public name for the latter is `container.health.changed`.
|
||||
let event = if raw.kind == "container.health_status" {
|
||||
"bread.container.health.changed".to_string()
|
||||
} else {
|
||||
format!("bread.{}", raw.kind)
|
||||
};
|
||||
vec![BreadEvent {
|
||||
event,
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Sibling `bread*` app events. Unlike the other sources, `raw.kind`
|
||||
/// already carries the full dotted event name (the IPC boundary builds
|
||||
/// it that way before construction), so this is validate-and-wrap, not
|
||||
/// a transform. The namespace check is defense in depth — the IPC layer
|
||||
/// already validates before constructing the `RawEvent` — so a
|
||||
/// malformed event here is dropped silently rather than treated as an
|
||||
/// adapter failure.
|
||||
fn normalize_app(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let AdapterSource::App(app) = &raw.source else {
|
||||
return vec![];
|
||||
};
|
||||
if !validate_app_namespace(app, &raw.kind) {
|
||||
return vec![];
|
||||
}
|
||||
vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn accept(&self, event: &BreadEvent) -> bool {
|
||||
// Terminal commands legitimately repeat (running the same command twice
|
||||
// in quick succession); the dedup window exists for noisy hardware
|
||||
// signals, not user-initiated terminal activity, so exempt it.
|
||||
if matches!(&event.source, AdapterSource::Terminal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let key = format!("{}:{}", event.event, event.data);
|
||||
let now = event.timestamp;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub struct RuntimeState {
|
|||
pub power: PowerState,
|
||||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
pub workflows: Vec<WorkflowStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -128,3 +129,34 @@ pub enum ModuleLoadState {
|
|||
Degraded,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Introspectable state for a `bread.workflow` instance, surfaced via the
|
||||
/// `workflows.list` IPC method. One entry per workflow *name* — starting a
|
||||
/// workflow with a name that's already running replaces its entry (this is
|
||||
/// a live-status registry, not a run history).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowStatus {
|
||||
pub name: String,
|
||||
pub state: WorkflowState,
|
||||
/// The most recent `bread.workflow.step(label)` call inside the body,
|
||||
/// if any.
|
||||
pub step: Option<String>,
|
||||
pub started_at: u64,
|
||||
pub updated_at: u64,
|
||||
/// Set when `state` is `Failed`: the captured Lua error message.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkflowState {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
/// The `opts.deadline` timer fired before the workflow reached a
|
||||
/// terminal state. Note: since a suspended coroutine isn't forcibly
|
||||
/// killed, it's possible (rare) for a workflow to still complete after
|
||||
/// this and overwrite the status again — this is a status marker, not
|
||||
/// a hard cancellation.
|
||||
TimedOut,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue