Switch to tag-pinned bread-ecosystem deps; bump version to v0.2.0
This commit is contained in:
parent
e16c1b461c
commit
7634af0b4f
8 changed files with 708 additions and 102 deletions
|
|
@ -9,3 +9,11 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
breadclip-core = { path = "../breadclip-core" }
|
||||
# TODO(owner): switch to a tag-pinned git dependency once bread-shared is
|
||||
# released/tagged for external consumption, matching the bread-theme pattern —
|
||||
# see the same stopgap already in place for bread-utils's own dependents.
|
||||
bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["bread-client"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
186
breadclipd/src/content_kind.rs
Normal file
186
breadclipd/src/content_kind.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
//! Heuristic classification of clipboard text, used to tag the
|
||||
//! `bread.clip.copied` event's `kind` field (see `bread-shared`'s namespace
|
||||
//! docs). Deliberately simple pattern matching, not a real parser or ML
|
||||
//! classifier — good enough to auto-tag "you just copied a stack trace" or
|
||||
//! "you just copied a URL" for a Lua automation module to react to, not a
|
||||
//! source of truth anyone should build a security decision on.
|
||||
|
||||
/// Checked in this order because a false-not-`error` or false-not-`url` is
|
||||
/// cheap to live with, while classifying a URL as generic `code` because it
|
||||
/// contains a `/` would be a worse user-facing miss.
|
||||
pub fn detect(text: &str) -> &'static str {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "plain";
|
||||
}
|
||||
if looks_like_error(trimmed) {
|
||||
"error"
|
||||
} else if looks_like_url(trimmed) {
|
||||
"url"
|
||||
} else if looks_like_path(trimmed) {
|
||||
"path"
|
||||
} else if looks_like_code(trimmed) {
|
||||
"code"
|
||||
} else {
|
||||
"plain"
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_url(text: &str) -> bool {
|
||||
// Single "word" (no internal whitespace) is required — a sentence that
|
||||
// merely contains a URL should classify as plain/error/code on its own
|
||||
// merits, not get tagged "url" just because a link appears in it.
|
||||
if text.split_whitespace().count() != 1 {
|
||||
return false;
|
||||
}
|
||||
let lower = text.to_ascii_lowercase();
|
||||
lower.starts_with("http://")
|
||||
|| lower.starts_with("https://")
|
||||
|| lower.starts_with("ftp://")
|
||||
|| lower.starts_with("file://")
|
||||
|| lower.starts_with("mailto:")
|
||||
}
|
||||
|
||||
fn looks_like_error(text: &str) -> bool {
|
||||
const MARKERS: [&str; 8] = [
|
||||
"traceback (most recent call last)",
|
||||
"panicked at",
|
||||
"exception in thread",
|
||||
"unhandled exception",
|
||||
"stack trace:",
|
||||
"at java.",
|
||||
"uncaught exception",
|
||||
"fatal error:",
|
||||
];
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if MARKERS.iter().any(|m| lower.contains(m)) {
|
||||
return true;
|
||||
}
|
||||
// A line starting "Error:"/"error:" combined with more than one line is
|
||||
// a common shape for compiler/runtime error output; a single bare line
|
||||
// like "error: 42" out of context is more likely just plain text.
|
||||
let first_line = lower.lines().next().unwrap_or("");
|
||||
text.lines().count() > 1
|
||||
&& (first_line.starts_with("error:") || first_line.starts_with("error["))
|
||||
}
|
||||
|
||||
fn looks_like_path(text: &str) -> bool {
|
||||
if text.split_whitespace().count() != 1 {
|
||||
return false;
|
||||
}
|
||||
let has_extension_or_depth = text
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.map(|s| s.contains('.'))
|
||||
.unwrap_or(false)
|
||||
|| text.matches('/').count() >= 2;
|
||||
(text.starts_with('/') || text.starts_with("~/") || text.starts_with("./"))
|
||||
&& has_extension_or_depth
|
||||
}
|
||||
|
||||
fn looks_like_code(text: &str) -> bool {
|
||||
const CODE_TOKENS: [&str; 14] = [
|
||||
"fn ",
|
||||
"function ",
|
||||
"def ",
|
||||
"class ",
|
||||
"const ",
|
||||
"let ",
|
||||
"import ",
|
||||
"#include",
|
||||
"public ",
|
||||
"private ",
|
||||
"=> {",
|
||||
"};",
|
||||
"</",
|
||||
"><",
|
||||
];
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let token_hits = CODE_TOKENS.iter().filter(|t| text.contains(*t)).count();
|
||||
let brace_or_semicolon_lines = lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let t = l.trim_end();
|
||||
t.ends_with(';') || t.ends_with('{') || t.ends_with('}')
|
||||
})
|
||||
.count();
|
||||
|
||||
// Multi-line with a meaningful fraction of "code-shaped" lines, or an
|
||||
// unambiguous single-line token (import/#include/fn signature), counts
|
||||
// as code. A single short line of prose won't hit either bar.
|
||||
token_hits >= 2
|
||||
|| (lines.len() > 1 && brace_or_semicolon_lines * 2 >= lines.len())
|
||||
|| (lines.len() == 1 && token_hits >= 1 && text.len() < 200)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_url() {
|
||||
assert_eq!(detect("https://example.com/path?query=1"), "url");
|
||||
assert_eq!(detect("http://localhost:8080"), "url");
|
||||
assert_eq!(detect(" https://example.com "), "url");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_tag_sentence_containing_a_url_as_url() {
|
||||
assert_eq!(detect("check out https://example.com later"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_python_traceback() {
|
||||
let text = "Traceback (most recent call last):\n File \"x.py\", line 1\nValueError: bad";
|
||||
assert_eq!(detect(text), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust_panic() {
|
||||
assert_eq!(
|
||||
detect("thread 'main' panicked at src/main.rs:10:5:\nindex out of bounds"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_multiline_error_prefixed_output() {
|
||||
assert_eq!(detect("error: expected `;`\n --> src/main.rs:2:1"), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_error_word_without_context_is_plain() {
|
||||
assert_eq!(detect("error: something"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_absolute_path() {
|
||||
assert_eq!(detect("/home/user/.config/bread/init.lua"), "path");
|
||||
assert_eq!(detect("~/Projects/bread/README.md"), "path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_multiline_code() {
|
||||
let text = "fn main() {\n let x = 1;\n println!(\"{}\", x);\n}";
|
||||
assert_eq!(detect(text), "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_single_line_import() {
|
||||
assert_eq!(detect("import numpy as np"), "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_prose_is_plain() {
|
||||
assert_eq!(
|
||||
detect("just a normal sentence someone copied from an email"),
|
||||
"plain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_whitespace_is_plain() {
|
||||
assert_eq!(detect(""), "plain");
|
||||
assert_eq!(detect(" \n "), "plain");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
mod content_kind;
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
use breadclip_core::HistoryDb;
|
||||
use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration};
|
||||
|
||||
/// This app's id in bread's sibling-app namespace registry
|
||||
/// (`bread_shared::apps::KNOWN_APPS`) — events are published as
|
||||
/// `bread.clip.*`, commands are received on `bread.command.clip.*`.
|
||||
const APP_ID: &str = "clip";
|
||||
|
||||
/// Argument used to invoke this same binary as the one-shot handler for
|
||||
/// `wl-paste --watch`. Kept as a plain positional arg (no clap dependency
|
||||
/// needed for a single internal flag).
|
||||
|
|
@ -99,19 +107,69 @@ fn capture_once() {
|
|||
|
||||
if has_image && !has_text {
|
||||
if let Some(bytes) = get_clipboard_image() {
|
||||
if let Err(e) = db.insert_image(&bytes) {
|
||||
eprintln!("breadclipd: insert image: {e}");
|
||||
match db.insert_image(&bytes) {
|
||||
Ok(()) => emit_copied("image", bytes.len()),
|
||||
Err(e) => eprintln!("breadclipd: insert image: {e}"),
|
||||
}
|
||||
}
|
||||
} else if has_text {
|
||||
if let Some(text) = get_clipboard_text() {
|
||||
if let Err(e) = db.insert_text(&text) {
|
||||
eprintln!("breadclipd: insert text: {e}");
|
||||
match db.insert_text(&text) {
|
||||
Ok(()) => emit_copied(content_kind::detect(&text), text.len()),
|
||||
Err(e) => eprintln!("breadclipd: insert text: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes `bread.clip.copied` into the bread event fabric. Fire-and-forget
|
||||
/// and non-fatal by design (`BreadClient::emit` never blocks or errors this
|
||||
/// caller) — breadd being absent or not installed must never affect
|
||||
/// breadclip's own clipboard-history functionality, only mean this one
|
||||
/// notification doesn't go anywhere.
|
||||
fn emit_copied(kind: &str, len: usize) {
|
||||
BreadClient::connect(APP_ID).emit(
|
||||
"bread.clip.copied",
|
||||
serde_json::json!({ "kind": kind, "len": len }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reacts to `bread.command.clip.*` verbs. Only `clear` maps to real,
|
||||
/// existing breadclip functionality today — `pin`/`select` would need a new
|
||||
/// "pinned" concept that doesn't exist anywhere in the history DB schema,
|
||||
/// which is a real product decision for breadclip itself (does it want
|
||||
/// pinning at all, and what would the GTK UI for it look like?), not
|
||||
/// something to fabricate as a side effect of wiring up the event bus. See
|
||||
/// `breadclip/EVENTS.md` for the honest current status.
|
||||
///
|
||||
/// Emits `bread.clip.<verb>.done`/`.failed` per the confirmation convention
|
||||
/// in bread's Documentation.md — a module that started this command via
|
||||
/// `bread.wait`/`bread.wait_any` can await the real outcome instead of
|
||||
/// assuming success the moment it publishes the command.
|
||||
fn handle_command(event_name: &str) {
|
||||
let Some(verb) = event_name.strip_prefix("bread.command.clip.") else {
|
||||
return;
|
||||
};
|
||||
match verb {
|
||||
"clear" => match HistoryDb::open().and_then(|db| db.clear_all()) {
|
||||
Ok(()) => {
|
||||
eprintln!("breadclipd: cleared history via bread.command.clip.clear");
|
||||
BreadClient::connect(APP_ID).emit("bread.clip.clear.done", serde_json::json!({}));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("breadclipd: bread.command.clip.clear failed: {e}");
|
||||
BreadClient::connect(APP_ID).emit(
|
||||
"bread.clip.clear.failed",
|
||||
serde_json::json!({ "error": e.to_string() }),
|
||||
);
|
||||
}
|
||||
},
|
||||
other => {
|
||||
eprintln!("breadclipd: ignoring unrecognized command verb '{other}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) {
|
||||
|
|
@ -153,6 +211,17 @@ fn main() {
|
|||
|
||||
eprintln!("breadclipd: started (pid {})", std::process::id());
|
||||
|
||||
// Long-lived, so this uses BreadClient::subscribe (a persistent
|
||||
// background thread with its own reconnect/backoff loop) rather than a
|
||||
// one-shot connection — the same client type capture_once() uses for
|
||||
// `emit`, just the other half of it. breadd being absent or restarting
|
||||
// is transparent here too: the subscription just quietly stops
|
||||
// delivering commands until it reconnects.
|
||||
let command_client = BreadClient::connect(APP_ID);
|
||||
let _commands = command_client.subscribe("bread.command.clip.**", |event| {
|
||||
handle_command(&event.event);
|
||||
});
|
||||
|
||||
// `wl-paste --watch <cmd>` runs <cmd> once per clipboard-change event
|
||||
// instead of polling — zero idle cost between changes, and no more
|
||||
// forking wl-paste 4-6 times a second. Each event re-invokes this same
|
||||
|
|
@ -175,3 +244,77 @@ fn main() {
|
|||
thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Isolates HistoryDb::open() to a fresh temp dir per test. `cargo test`
|
||||
// runs tests in parallel threads within one process by default, and
|
||||
// `XDG_DATA_HOME` is process-global — three tests in this module all
|
||||
// set it, so without this lock they race each other (confirmed: an
|
||||
// earlier version of this file had that exact bug, caught by a flaky
|
||||
// `len() == 2` instead of `1` failure).
|
||||
fn env_test_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
fn with_isolated_history<F: FnOnce()>(f: F) {
|
||||
let _guard = env_test_lock().lock().unwrap_or_else(|p| p.into_inner());
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
std::env::set_var("XDG_DATA_HOME", dir.path());
|
||||
f();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_command_clear_empties_history_even_with_no_daemon_reachable() {
|
||||
// The point of this test: handle_command's actual effect (clearing
|
||||
// the DB) must not depend on breadd being reachable — `emit` inside
|
||||
// it is fire-and-forget and must never gate the real work. No test
|
||||
// daemon is running here, so if clearing the DB were accidentally
|
||||
// made contingent on the emit succeeding, this test would fail.
|
||||
with_isolated_history(|| {
|
||||
let db = HistoryDb::open().expect("open history db");
|
||||
db.insert_text("something to clear").unwrap();
|
||||
assert_eq!(db.list_entries(10).unwrap().len(), 1);
|
||||
|
||||
handle_command("bread.command.clip.clear");
|
||||
|
||||
let db = HistoryDb::open().expect("reopen history db");
|
||||
assert!(db.list_entries(10).unwrap().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_command_ignores_unrecognized_verb() {
|
||||
with_isolated_history(|| {
|
||||
let db = HistoryDb::open().expect("open history db");
|
||||
db.insert_text("should survive").unwrap();
|
||||
|
||||
handle_command("bread.command.clip.pin");
|
||||
|
||||
let db = HistoryDb::open().expect("reopen history db");
|
||||
assert_eq!(
|
||||
db.list_entries(10).unwrap().len(),
|
||||
1,
|
||||
"an unrecognized verb must not touch history"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
||||
with_isolated_history(|| {
|
||||
let db = HistoryDb::open().expect("open history db");
|
||||
db.insert_text("should survive").unwrap();
|
||||
|
||||
// Not a `bread.command.clip.*` event at all — must be a no-op.
|
||||
handle_command("bread.command.pad.clear");
|
||||
handle_command("bread.clip.copied");
|
||||
|
||||
let db = HistoryDb::open().expect("reopen history db");
|
||||
assert_eq!(db.list_entries(10).unwrap().len(), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue