Skip to main content

agx_core/
notify.rs

1//! Desktop notifications for `--live` mode — fires when the watched
2//! session grows with a new error tool_result, or when it stops growing
3//! for a user-specified duration. Opt-in via `--features notifications`.
4//!
5//! # Why feature-gated
6//!
7//! `notify-rust` pulls in platform-native bindings: D-Bus on Linux,
8//! AppKit on macOS, WinRT on Windows. Users who don't run `agx --live`
9//! don't need any of that. Gating keeps the default binary lean and
10//! lets this feature co-exist with the equally optional
11//! `embedding-search` and `otel-proto` features without coupling.
12//!
13//! # Fallback behavior
14//!
15//! Without the feature:
16//! - `error(…)` / `idle(…)` are no-ops; they return `Ok(())` without
17//!   doing anything. Callers never need a `cfg!` check.
18//! - `FEATURE_DISABLED_MESSAGE` exists for the Cli dispatch in
19//!   main.rs to print a one-time rebuild hint when the user passes
20//!   `--notify-on-error` / `--notify-on-idle` on a feature-off build.
21//!
22//! With the feature:
23//! - `error(step_label)` fires a notification with title "agx: error
24//!   in live session" and the step label as the body. Best-effort —
25//!   OS notification failures return `Err` but the live loop is
26//!   expected to `.ok()` them so transient D-Bus hiccups don't crash
27//!   the TUI.
28//! - `idle(duration_s)` fires a notification with title "agx: live
29//!   session idle" when the file has not grown for the configured
30//!   duration.
31//!
32//! # Scope
33//!
34//! This module deliberately doesn't own the *when* to fire — that's
35//! `tui.rs`'s business. The module is a thin wrapper over
36//! `notify-rust`'s notification API so the tui.rs event loop can
37//! stay format-native without dragging platform deps into its tree.
38
39use anyhow::Result;
40
41/// User-facing message shown the first time a notification flag is
42/// used on a feature-off build. Tells users exactly how to rebuild.
43/// Public because the bin crate's main.rs prints it from its CLI
44/// dispatch layer.
45pub const FEATURE_DISABLED_MESSAGE: &str = "--notify-on-* requires rebuilding agx with `cargo install agx --features notifications` or `cargo build --release --features notifications`";
46
47/// Fire an error notification. Best-effort — failures return `Err` but
48/// the live loop should `.ok()` them so transient OS-notification
49/// hiccups never crash the TUI.
50#[cfg(not(feature = "notifications"))]
51pub fn error(_step_label: &str) -> Result<()> {
52    Ok(())
53}
54
55/// Fire an idle notification. Best-effort.
56#[cfg(not(feature = "notifications"))]
57pub fn idle(_duration_s: u64) -> Result<()> {
58    Ok(())
59}
60
61#[cfg(feature = "notifications")]
62pub fn error(step_label: &str) -> Result<()> {
63    real::error(step_label)
64}
65
66#[cfg(feature = "notifications")]
67pub fn idle(duration_s: u64) -> Result<()> {
68    real::idle(duration_s)
69}
70
71#[cfg(feature = "notifications")]
72mod real {
73    use anyhow::{Context, Result};
74    use notify_rust::Notification;
75
76    pub(super) fn error(step_label: &str) -> Result<()> {
77        Notification::new()
78            .summary("agx: error in live session")
79            .body(step_label)
80            .appname("agx")
81            .show()
82            .with_context(|| "failed to emit error notification")?;
83        Ok(())
84    }
85
86    pub(super) fn idle(duration_s: u64) -> Result<()> {
87        let body = format!("No new steps for {duration_s}s");
88        Notification::new()
89            .summary("agx: live session idle")
90            .body(&body)
91            .appname("agx")
92            .show()
93            .with_context(|| "failed to emit idle notification")?;
94        Ok(())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn feature_disabled_message_mentions_rebuild_hint() {
104        assert!(FEATURE_DISABLED_MESSAGE.contains("--features notifications"));
105    }
106
107    #[cfg(not(feature = "notifications"))]
108    #[test]
109    fn error_and_idle_are_noops_without_feature() {
110        // Feature-off path must not error — callers rely on it to be
111        // a silent no-op so the live event loop can blindly call it.
112        assert!(error("anything").is_ok());
113        assert!(idle(30).is_ok());
114    }
115}