Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers

This commit is contained in:
Breadway 2026-07-19 03:05:57 +08:00
parent 89c5849539
commit 1208c5d1b7
29 changed files with 4098 additions and 339 deletions

113
bread-shared/src/apps.rs Normal file
View file

@ -0,0 +1,113 @@
//! The known-apps registry for sibling `bread*` application integration.
//!
//! Adding a new sibling app is a one-line edit to [`KNOWN_APPS`] — no new
//! `AdapterSource` variant, no new normalizer arm, no recompile-driven
//! exhaustiveness churn. This is what makes the sibling-app integration
//! model extensible: see `Documentation.md`'s "Namespaces" section and
//! "Integrating a bread* app" recipe.
/// Registered sibling `bread*` app ids. Each id is also the app's reserved
/// segment in the `bread.<id>.*` (events) and `bread.command.<id>.*`
/// (commands) namespaces.
pub const KNOWN_APPS: &[&str] = &[
"clip", "pad", "bar", "box", "lock", "mon", "paper", "search", "shot", "arr", "crumbs", "help",
"bakery",
];
/// Daemon-internal domains that are reserved and can never be claimed as an
/// app id, even if a future `bread*` app would otherwise want that name —
/// these are the top-level segments the normalizer and built-in event
/// families already use.
const RESERVED_DOMAINS: &[&str] = &[
"terminal",
"git",
"hyprland",
"device",
"power",
"network",
"service",
"container",
"project",
"remote",
"system",
"profile",
"notify",
"command",
"workflow",
];
/// Whether `id` is a registered sibling-app id.
pub fn is_known_app(id: &str) -> bool {
KNOWN_APPS.contains(&id)
}
/// Whether `id` is reserved for daemon-internal use and can never be
/// registered as a sibling-app id.
pub fn is_reserved_domain(id: &str) -> bool {
RESERVED_DOMAINS.contains(&id)
}
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
/// with `bread.<app>.`. An app may only publish within its own namespace
/// segment; this is what the IPC boundary checks before constructing a
/// `RawEvent` tagged `AdapterSource::App(app)`.
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
event.starts_with(&format!("bread.{app}."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_apps_are_recognized() {
assert!(is_known_app("clip"));
assert!(is_known_app("bakery"));
}
#[test]
fn unknown_app_is_not_recognized() {
assert!(!is_known_app("notanapp"));
assert!(!is_known_app(""));
}
#[test]
fn reserved_domains_are_never_known_apps() {
for domain in RESERVED_DOMAINS {
assert!(
!is_known_app(domain),
"reserved domain '{domain}' must not double as a known app id"
);
}
}
#[test]
fn reserved_domains_are_recognized() {
assert!(is_reserved_domain("power"));
assert!(is_reserved_domain("hyprland"));
assert!(!is_reserved_domain("clip"));
}
#[test]
fn validate_app_namespace_accepts_own_namespace() {
assert!(validate_app_namespace("clip", "bread.clip.copied"));
assert!(validate_app_namespace(
"clip",
"bread.clip.stack_trace.captured"
));
}
#[test]
fn validate_app_namespace_rejects_other_namespaces() {
assert!(!validate_app_namespace("clip", "bread.pad.reminder.due"));
assert!(!validate_app_namespace("clip", "bread.power.ac.connected"));
assert!(!validate_app_namespace("clip", "bread.clipboard.copied"));
}
#[test]
fn validate_app_namespace_rejects_bare_prefix_without_trailing_dot() {
// "bread.clipx..." must not satisfy the "clip" namespace just
// because it shares a string prefix.
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
}
}

View file

@ -155,12 +155,18 @@ mod tests {
#[test]
fn dot_double_star_does_not_match_sibling_prefix() {
assert!(!matches_pattern("bread.device.**", "bread.devicex"));
assert!(!matches_pattern("bread.device.**", "bread.network.connected"));
assert!(!matches_pattern(
"bread.device.**",
"bread.network.connected"
));
}
#[test]
fn mid_pattern_star_does_not_cross_dots() {
assert!(matches_pattern("bread.*.connected", "bread.alpha.connected"));
assert!(matches_pattern(
"bread.*.connected",
"bread.alpha.connected"
));
assert!(!matches_pattern(
"bread.*.connected",
"bread.alpha.beta.connected"

View file

@ -8,13 +8,19 @@
use serde::{Deserialize, Serialize};
pub mod apps;
pub mod glob;
/// 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)]
///
/// Not `Copy`: the [`App`](AdapterSource::App) variant carries an owned
/// `String` (a sibling `bread*` app id), so callers that used to copy a
/// `AdapterSource` by value now `.clone()` it — see `breadd/src/core/normalizer.rs`
/// for the (small, compiler-driven) set of call sites this touches.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AdapterSource {
/// The Hyprland compositor IPC socket.
@ -30,6 +36,23 @@ pub enum AdapterSource {
System,
/// BlueZ Bluetooth stack via D-Bus.
Bluetooth,
/// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes).
Terminal,
/// Git hooks (commit/branch) and the in-daemon dirty-state poller.
Git,
/// Project-root file watches (via `notify`/inotify).
Filesystem,
/// systemd --user unit state, via the session D-Bus.
Systemd,
/// Podman container lifecycle, via `podman events`.
Podman,
/// SSH/remote session detection, via the shell hook.
Remote,
/// A sibling `bread*` application (breadclip, breadpad, ...), identified
/// by its registered app id (see [`apps::KNOWN_APPS`]). Confined to the
/// `bread.<app>.*` event namespace — enforced at the IPC boundary via
/// [`apps::validate_app_namespace`], not by this type itself.
App(String),
}
/// An unnormalized event as emitted by an adapter.
@ -91,6 +114,44 @@ pub fn now_unix_ms() -> u64 {
.as_millis() as u64
}
#[derive(Deserialize, Default)]
struct DaemonSection {
#[serde(default)]
socket_path: String,
}
#[derive(Deserialize, Default)]
struct SocketPathConfig {
#[serde(default)]
daemon: DaemonSection,
}
/// Resolve breadd's Unix socket path exactly as `breadd::core::config::Config::socket_path`
/// resolves its own: an explicit `daemon.socket_path` in `~/.config/bread/breadd.toml` wins,
/// otherwise `$XDG_RUNTIME_DIR/bread/breadd.sock`, falling back to `/tmp/bread/breadd.sock`.
///
/// Shared by every socket client that lives outside the daemon itself (`bread-emit`, and
/// `bread-client` in `bread-ecosystem/bread-utils`) so they can't drift from how the daemon
/// actually resolves its own socket — before this existed, `bread-emit` carried its own
/// hand-rolled copy of this exact logic.
pub fn resolve_socket_path() -> std::path::PathBuf {
if let Some(home) = dirs::home_dir() {
let config_path = home.join(".config/bread/breadd.toml");
if let Ok(contents) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = toml::from_str::<SocketPathConfig>(&contents) {
if !cfg.daemon.socket_path.is_empty() {
return expand_path(&cfg.daemon.socket_path);
}
}
}
}
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
std::path::PathBuf::from(runtime_dir)
.join("bread")
.join("breadd.sock")
}
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
///
/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps
@ -164,6 +225,38 @@ mod tests {
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Terminal).unwrap(),
"\"terminal\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Git).unwrap(),
"\"git\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Filesystem).unwrap(),
"\"filesystem\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Systemd).unwrap(),
"\"systemd\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Podman).unwrap(),
"\"podman\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Remote).unwrap(),
"\"remote\""
);
}
#[test]
fn adapter_source_app_serializes_as_externally_tagged_object() {
assert_eq!(
serde_json::to_string(&AdapterSource::App("clip".to_string())).unwrap(),
"{\"app\":\"clip\"}"
);
}
#[test]
@ -175,6 +268,13 @@ mod tests {
AdapterSource::Network,
AdapterSource::System,
AdapterSource::Bluetooth,
AdapterSource::Terminal,
AdapterSource::Git,
AdapterSource::Filesystem,
AdapterSource::Systemd,
AdapterSource::Podman,
AdapterSource::Remote,
AdapterSource::App("clip".to_string()),
] {
let s = serde_json::to_string(&source).unwrap();
let back: AdapterSource = serde_json::from_str(&s).unwrap();