ao_core/notifier.rs
1//! Notifier plugin contract + registry — Slice 3 Phase A (data only).
2//!
3//! Slice 3 turns `ReactionAction::Notify` from "emit a `ReactionTriggered`
4//! event and hope a subscriber is listening" into real fan-out to
5//! configurable channels (stdout, ntfy, desktop, slack, …).
6//!
7//! ## Phase split
8//!
9//! - **Phase A (this module)** — `Notifier` trait, `NotificationPayload`,
10//! `NotifierError`, `NotificationRouting` config type, `NotifierRegistry`.
11//! No engine integration, no plugin crates. The types land first so
12//! they can be reviewed before anything calls them.
13//! - **Phase B** — `ReactionEngine::dispatch_notify` resolves a priority
14//! through the registry and calls `Notifier::send` on each target,
15//! aggregating results into `ReactionOutcome`. Uses the test-only
16//! `TestNotifier` below for coverage — still no plugin crates.
17//! - **Phase C** — first real plugin crate `ao-plugin-notifier-stdout`,
18//! wired in `ao-cli` with a default-to-stdout policy when the routing
19//! table is empty.
20//! - **Phase D+** — additional plugin crates (ntfy, desktop, slack, …).
21//!
22//! See `docs/ai/design/feature-notifier-routing.md` for the full Slice 3
23//! arc and the rationale for each design choice.
24//!
25//! ## Why data-only for Phase A
26//!
27//! Landing the trait, payload, error, routing config, and registry as
28//! one focused commit gives reviewers a stable contract to evaluate
29//! before any call sites depend on it. Mirrors the Phase A commit for
30//! Slice 2 (reaction config types only) that preceded the engine wiring
31//! in Phase D.
32//!
33//! Mirrors the `Notifier` / `NotificationPayload` / `notificationRouting`
34//! types in `packages/core/src/types.ts` (TS reference).
35
36use crate::{
37 reactions::{EventPriority, ReactionAction},
38 types::SessionId,
39};
40use async_trait::async_trait;
41use serde::{Deserialize, Serialize};
42use std::{
43 collections::{HashMap, HashSet},
44 sync::{Arc, Mutex},
45};
46
47// ---------------------------------------------------------------------------
48// NotificationPayload
49// ---------------------------------------------------------------------------
50
51/// Data handed to every `Notifier::send` call.
52///
53/// Constructed by `ReactionEngine::dispatch_notify` at Phase B and
54/// later. Phase A only defines the shape so plugins can be written
55/// against a stable target.
56///
57/// Not `Serialize` — the payload lives entirely in-process, never hits
58/// disk, and never rides the event bus (the bus carries narrow
59/// `OrchestratorEvent` variants for fan-out, not rich payloads).
60/// Keeping it off serde means plugins are free to embed non-serde
61/// types (handles, closures, Instants) later without breaking the
62/// type boundary.
63#[derive(Debug, Clone)]
64pub struct NotificationPayload {
65 /// Session the notification is about.
66 pub session_id: SessionId,
67 /// Reaction key that fired (e.g. `"ci-failed"`).
68 pub reaction_key: String,
69 /// Action the engine actually took — always `Notify` at the call
70 /// site, but carried for plugins that want to log/format it.
71 pub action: ReactionAction,
72 /// Priority chosen by the engine for this fire. Decides routing.
73 pub priority: EventPriority,
74 /// One-line title. Synthesized by the engine from `reaction_key +
75 /// session` in Phase B.
76 pub title: String,
77 /// Body text. Pulled from `ReactionConfig.message` when set,
78 /// otherwise engine-supplied boilerplate.
79 pub body: String,
80 /// `true` if this notify is the escalation fallback after retries
81 /// were exhausted (engine swapped `SendToAgent` → `Notify`).
82 /// Plugins that want to badge "escalated" branch on this.
83 pub escalated: bool,
84}
85
86// ---------------------------------------------------------------------------
87// NotifierError
88// ---------------------------------------------------------------------------
89
90/// Plugin-returned error type.
91///
92/// Every variant is treated identically by the engine: logged via
93/// `tracing::warn!`, recorded in `ReactionOutcome { success: false, .. }`,
94/// and never propagated up to the polling loop. A flaky notifier must
95/// not wedge the tick — matches the "never poison the engine" principle
96/// used for malformed durations in Slice 2 Phase H.
97///
98/// The variant split exists so plugin authors have a reasonable place
99/// to put their own errors without inventing a new enum per plugin.
100/// HTTP plugins lean on `Service` + `Timeout`; desktop plugins lean on
101/// `Unavailable`; anything that failed before the wire lean on `Config`
102/// or `Io`.
103#[derive(Debug, thiserror::Error)]
104pub enum NotifierError {
105 /// Local I/O failed — filesystem, stdout, named pipe, etc.
106 #[error("notifier I/O failure: {0}")]
107 Io(String),
108 /// Plugin configuration is invalid or incomplete (missing token,
109 /// unparseable URL, …).
110 #[error("notifier configuration error: {0}")]
111 Config(String),
112 /// External service returned a non-success status.
113 #[error("notifier external service error: {status}: {message}")]
114 Service { status: u16, message: String },
115 /// Plugin exceeded its own timeout budget before the service
116 /// responded.
117 #[error("notifier timed out after {elapsed_ms}ms")]
118 Timeout { elapsed_ms: u64 },
119 /// External service or local dependency is unreachable right now
120 /// (connection refused, DNS failure, desktop daemon missing).
121 #[error("notifier unavailable: {0}")]
122 Unavailable(String),
123}
124
125// ---------------------------------------------------------------------------
126// Notifier trait
127// ---------------------------------------------------------------------------
128
129/// Plugin contract for delivering notifications.
130///
131/// One method + one associated function. Plugins live in their own
132/// crates under `ao-plugin-notifier-*` starting in Phase C; the first
133/// real plugin is stdout.
134///
135/// ## Implementor responsibilities
136///
137/// - **Never panic.** Return a `NotifierError` variant instead. The
138/// engine traps errors but panics would tear down the polling task.
139/// - **Respect a bounded timeout.** HTTP plugins should default to 5s
140/// and map overruns to `NotifierError::Timeout`. The trait signature
141/// does not enforce this; it's a hard convention.
142/// - **Don't hold locks across `.await`.** The engine calls `send`
143/// inline during a poll tick and a deadlocked plugin would wedge the
144/// whole loop.
145/// - **Keep `send` side-effect-only.** Payload mutation is out of
146/// scope — plugins receive `&NotificationPayload` precisely so they
147/// can't rewrite history for downstream plugins in the same fan-out.
148///
149/// ## Concurrency
150///
151/// Implementors must be `Send + Sync` because the registry stores
152/// `Arc<dyn Notifier>` and the engine runs inside a `tokio::spawn`
153/// task. Matches the rest of the `ao-core` plugin traits.
154#[async_trait]
155pub trait Notifier: Send + Sync {
156 /// Canonical name used in the `notification-routing` table.
157 /// Conventionally kebab-case (`"stdout"`, `"ntfy"`, `"slack"`).
158 /// Must be stable across the plugin's lifetime.
159 fn name(&self) -> &str;
160
161 /// Deliver one notification.
162 ///
163 /// Returning `Err` does not crash the engine — the engine logs via
164 /// `tracing::warn!`, marks the `ReactionOutcome` as `success =
165 /// false`, and proceeds to the next plugin in the fan-out.
166 async fn send(&self, payload: &NotificationPayload) -> Result<(), NotifierError>;
167}
168
169// ---------------------------------------------------------------------------
170// NotificationRouting
171// ---------------------------------------------------------------------------
172
173/// Priority-based routing table read from the `notification-routing:`
174/// section of `~/.ao-rs/config.yaml`.
175///
176/// On-disk YAML:
177///
178/// ```yaml
179/// notification-routing:
180/// urgent: [stdout, ntfy]
181/// action: [stdout, ntfy]
182/// warning: [stdout]
183/// info: [stdout]
184/// ```
185///
186/// Stored as a newtype around `HashMap<EventPriority, Vec<String>>`
187/// with `#[serde(transparent)]` so the on-disk form is just the map —
188/// no wrapper key. Hiding the inner `HashMap` behind `names_for` keeps
189/// the public API stable if we later want to change the container or
190/// bolt on a per-reaction-key override layer.
191///
192/// Default: empty map. An empty table means "nothing configured for
193/// any priority" — `NotifierRegistry::resolve` warn-onces per priority
194/// on the first miss and drops the notification. The fallback policy
195/// (default-to-stdout when the table is empty) belongs one layer up
196/// at the `ao-cli` wiring site in Phase C, not inside the config
197/// type itself, so this module stays pure data.
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(transparent)]
200pub struct NotificationRouting(HashMap<EventPriority, Vec<String>>);
201
202impl NotificationRouting {
203 /// Return the list of notifier names registered for this priority,
204 /// or `None` if the priority has no entry.
205 ///
206 /// An empty list (priority present but points at `[]`) is returned
207 /// as `Some(&[])` — distinct from a missing entry. The registry's
208 /// `resolve` method folds both cases together (warn-once + empty
209 /// result) so callers don't need to branch on the difference, but
210 /// they CAN if they ever want to.
211 pub fn names_for(&self, priority: EventPriority) -> Option<&[String]> {
212 self.0.get(&priority).map(Vec::as_slice)
213 }
214
215 /// True if the routing table has no priorities configured at all.
216 /// The `ao-cli` wiring uses this in Phase C to decide whether to
217 /// apply the "default to stdout for everything" fallback.
218 pub fn is_empty(&self) -> bool {
219 self.0.is_empty()
220 }
221
222 /// Number of priorities that have at least one entry.
223 pub fn len(&self) -> usize {
224 self.0.len()
225 }
226
227 /// Construct a routing table from a pre-built map. Used by
228 /// `ao-cli` to build the default-to-stdout routing when the user's
229 /// config has no `notification-routing:` section, and by unit tests
230 /// that want an inline table without going through serde.
231 pub fn from_map(map: HashMap<EventPriority, Vec<String>>) -> Self {
232 Self(map)
233 }
234}
235
236// ---------------------------------------------------------------------------
237// NotifierRegistry
238// ---------------------------------------------------------------------------
239
240/// Runtime-side registry of notifier plugins keyed by name, plus the
241/// routing table that decides which plugins receive each priority.
242///
243/// Constructed in `ao-cli` (Phase C) after plugin instantiation,
244/// attached to `ReactionEngine` via `with_notifier_registry` (Phase B).
245/// Existing call sites that don't attach one keep working — identical
246/// opt-in pattern to `ReactionEngine::with_scm`.
247///
248/// ## Warn-once policy
249///
250/// `resolve` logs exactly one `tracing::warn!` per distinct
251/// `(priority, notifier_name)` pair across the process lifetime, so a
252/// typo in the routing table can't spam the log on every poll tick.
253/// Matches the dedup pattern used by
254/// `reaction_engine::warn_once_parse_failure` for malformed durations.
255pub struct NotifierRegistry {
256 plugins: HashMap<String, Arc<dyn Notifier>>,
257 routing: NotificationRouting,
258 /// Dedup set for `resolve`'s warn-once emits. Keys are one of:
259 /// - `"priority.{priority}"` for missing or empty priority entries
260 /// - `"{priority}.{notifier_name}"` for names with no registered
261 /// matching plugin
262 ///
263 /// `Mutex` (not `RwLock`) because the set is write-mostly: every
264 /// miss either inserts a new key or short-circuits on an existing
265 /// one. Lock is held narrowly — acquire, check-and-insert, drop,
266 /// *then* call `tracing::warn!`.
267 warned: Mutex<HashSet<String>>,
268}
269
270impl NotifierRegistry {
271 /// Construct an empty registry with the given routing table. Plugins
272 /// are added via `register`.
273 pub fn new(routing: NotificationRouting) -> Self {
274 Self {
275 plugins: HashMap::new(),
276 routing,
277 warned: Mutex::new(HashSet::new()),
278 }
279 }
280
281 /// Register a plugin under a name. Overwrites any existing entry
282 /// for the same name — tests rely on this to stub plugins with
283 /// replacements. Production wiring in `ao-cli` registers each
284 /// plugin exactly once at startup.
285 pub fn register(&mut self, name: impl Into<String>, plugin: Arc<dyn Notifier>) {
286 self.plugins.insert(name.into(), plugin);
287 }
288
289 /// Look up a plugin by name without going through routing.
290 /// Primarily useful for `ao-cli` smoke tests and for future phases
291 /// that may want direct-addressed notifications.
292 pub fn get(&self, name: &str) -> Option<Arc<dyn Notifier>> {
293 self.plugins.get(name).cloned()
294 }
295
296 /// Number of registered plugins.
297 pub fn len(&self) -> usize {
298 self.plugins.len()
299 }
300
301 /// `true` if no plugins have been registered.
302 pub fn is_empty(&self) -> bool {
303 self.plugins.is_empty()
304 }
305
306 /// Resolve a priority against the routing table, returning the
307 /// `(name, plugin)` pairs the engine should dispatch to.
308 ///
309 /// Empty return vec means "do nothing for this priority". That
310 /// happens in three cases, all of which trigger a warn-once:
311 ///
312 /// 1. Priority missing from the routing table entirely.
313 /// 2. Priority present but points at an empty list.
314 /// 3. The routing table names one or more plugins that are not
315 /// registered — the registered subset (if any) is returned and
316 /// the missing names are each warned once.
317 ///
318 /// Case 3 can return a non-empty vec (the registered subset) even
319 /// though some of the configured names were missing. That is
320 /// deliberate: a partially-wired routing table should still deliver
321 /// to the plugins that DO exist, not fail closed.
322 pub fn resolve(&self, priority: EventPriority) -> Vec<(String, Arc<dyn Notifier>)> {
323 let Some(names) = self.routing.names_for(priority) else {
324 self.warn_once(format!("priority.{}", priority.as_str()), || {
325 tracing::warn!(
326 priority = priority.as_str(),
327 "notification-routing has no entry for priority; notification dropped"
328 );
329 });
330 return Vec::new();
331 };
332
333 if names.is_empty() {
334 self.warn_once(format!("priority.{}", priority.as_str()), || {
335 tracing::warn!(
336 priority = priority.as_str(),
337 "notification-routing has an empty list for priority; notification dropped"
338 );
339 });
340 return Vec::new();
341 }
342
343 let mut out = Vec::with_capacity(names.len());
344 for name in names {
345 if let Some(plugin) = self.plugins.get(name) {
346 out.push((name.clone(), plugin.clone()));
347 } else {
348 let key = format!("{}.{}", priority.as_str(), name);
349 let missing_name = name.clone();
350 self.warn_once(key, || {
351 tracing::warn!(
352 priority = priority.as_str(),
353 notifier = missing_name.as_str(),
354 "notification-routing references unregistered notifier; skipping"
355 );
356 });
357 }
358 }
359 out
360 }
361
362 /// Dedup helper. Acquires `warned` narrowly — insert, drop lock,
363 /// then invoke `emit`. Matches the lock discipline used by
364 /// `reaction_engine::warn_once_parse_failure` (Phase H) so a
365 /// future `tracing::warn!` macro expansion that panics inside the
366 /// formatter can never poison the mutex while it's held.
367 fn warn_once<F: FnOnce()>(&self, key: String, emit: F) {
368 let fire = {
369 let mut set = self.warned.lock().unwrap_or_else(|e| {
370 tracing::error!(
371 "notifier registry warned mutex poisoned; recovering inner state: {e}"
372 );
373 e.into_inner()
374 });
375 set.insert(key)
376 };
377 if fire {
378 emit();
379 }
380 }
381
382 /// Test-only accessor for the dedup set size. Production code
383 /// must treat `warned` as opaque.
384 #[cfg(test)]
385 pub(crate) fn warned_count(&self) -> usize {
386 self.warned
387 .lock()
388 .unwrap_or_else(|e| {
389 tracing::error!(
390 "notifier registry warned mutex poisoned; recovering inner state: {e}"
391 );
392 e.into_inner()
393 })
394 .len()
395 }
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403pub(crate) mod tests {
404 use super::*;
405 use std::sync::Mutex as StdMutex;
406
407 /// Records every `send` call for inspection by tests. Lives in the
408 /// `tests` module but is `pub(crate)` so Phase B's `reaction_engine`
409 /// tests can import it: `use crate::notifier::tests::TestNotifier`.
410 ///
411 /// The inner mutex wraps a `Vec` of owned payloads. `send` is
412 /// async but we never hold the lock across `.await` (we don't have
413 /// an await point inside this impl at all), so `std::sync::Mutex`
414 /// is fine — a `tokio::sync::Mutex` would be overkill.
415 pub(crate) struct TestNotifier {
416 name: String,
417 received: Arc<StdMutex<Vec<NotificationPayload>>>,
418 }
419
420 impl TestNotifier {
421 pub(crate) fn new(
422 name: impl Into<String>,
423 ) -> (Self, Arc<StdMutex<Vec<NotificationPayload>>>) {
424 let received = Arc::new(StdMutex::new(Vec::new()));
425 (
426 Self {
427 name: name.into(),
428 received: Arc::clone(&received),
429 },
430 received,
431 )
432 }
433 }
434
435 #[async_trait]
436 impl Notifier for TestNotifier {
437 fn name(&self) -> &str {
438 &self.name
439 }
440
441 async fn send(&self, payload: &NotificationPayload) -> Result<(), NotifierError> {
442 self.received
443 .lock()
444 .unwrap_or_else(|e| {
445 tracing::error!("test notifier mutex poisoned; recovering inner state: {e}");
446 e.into_inner()
447 })
448 .push(payload.clone());
449 Ok(())
450 }
451 }
452
453 fn fake_payload(priority: EventPriority) -> NotificationPayload {
454 NotificationPayload {
455 session_id: SessionId("sess-test".into()),
456 reaction_key: "ci-failed".into(),
457 action: ReactionAction::Notify,
458 priority,
459 title: "CI broke on sess-test".into(),
460 body: "tests failed on main".into(),
461 escalated: false,
462 }
463 }
464
465 // ---- NotificationRouting ----
466
467 #[test]
468 fn routing_default_is_empty() {
469 let r = NotificationRouting::default();
470 assert!(r.is_empty());
471 assert_eq!(r.len(), 0);
472 assert!(r.names_for(EventPriority::Urgent).is_none());
473 }
474
475 #[test]
476 fn routing_yaml_round_trip() {
477 let yaml = r#"
478urgent: [stdout, ntfy]
479action: [stdout, ntfy]
480warning: [stdout]
481info: [stdout]
482"#;
483 let parsed: NotificationRouting = serde_yaml::from_str(yaml).unwrap();
484 assert_eq!(parsed.len(), 4);
485 assert_eq!(
486 parsed.names_for(EventPriority::Urgent).unwrap(),
487 &["stdout".to_string(), "ntfy".to_string()]
488 );
489 assert_eq!(
490 parsed.names_for(EventPriority::Info).unwrap(),
491 &["stdout".to_string()]
492 );
493
494 // Round-trip through YAML: serialize back, re-parse, equals original.
495 let back = serde_yaml::to_string(&parsed).unwrap();
496 let again: NotificationRouting = serde_yaml::from_str(&back).unwrap();
497 assert_eq!(parsed, again);
498 }
499
500 #[test]
501 fn routing_rejects_unknown_priority_key() {
502 // Strict priority matching: a typo ("critical") must fail the
503 // parse, not be silently dropped. Locks in behaviour so a
504 // future serde change (e.g. `#[serde(other)]`) can't flip it
505 // without this test failing first.
506 let yaml = "critical: [stdout]\n";
507 let result: std::result::Result<NotificationRouting, _> = serde_yaml::from_str(yaml);
508 assert!(
509 result.is_err(),
510 "expected parse error for unknown priority, got {result:?}"
511 );
512 }
513
514 #[test]
515 fn routing_preserves_empty_list_distinct_from_missing() {
516 // `warning: []` is preserved as Some(&[]), NOT folded into
517 // None. `resolve` folds them together for the engine, but the
518 // distinction is visible at the config layer so tooling can
519 // tell them apart if it ever wants to.
520 let yaml = "warning: []\n";
521 let parsed: NotificationRouting = serde_yaml::from_str(yaml).unwrap();
522 assert_eq!(parsed.names_for(EventPriority::Warning), Some(&[][..]));
523 assert!(parsed.names_for(EventPriority::Urgent).is_none());
524 }
525
526 // ---- NotifierRegistry ----
527
528 #[test]
529 fn registry_new_is_empty() {
530 let r = NotifierRegistry::new(NotificationRouting::default());
531 assert!(r.is_empty());
532 assert_eq!(r.len(), 0);
533 assert!(r.get("stdout").is_none());
534 }
535
536 #[test]
537 fn registry_register_and_get_round_trip() {
538 let (tn, _received) = TestNotifier::new("stdout");
539 let mut reg = NotifierRegistry::new(NotificationRouting::default());
540 reg.register("stdout", Arc::new(tn));
541 assert_eq!(reg.len(), 1);
542 let got = reg.get("stdout").expect("plugin should be registered");
543 assert_eq!(got.name(), "stdout");
544 }
545
546 #[test]
547 fn registry_register_overwrites_existing() {
548 // Two plugins registered under the same name — the second
549 // replaces the first. Documented behaviour so tests can
550 // reliably stub plugins with replacements.
551 let (first, _) = TestNotifier::new("first");
552 let (second, _) = TestNotifier::new("second");
553 let mut reg = NotifierRegistry::new(NotificationRouting::default());
554 reg.register("slot", Arc::new(first));
555 reg.register("slot", Arc::new(second));
556 assert_eq!(reg.len(), 1);
557 assert_eq!(reg.get("slot").unwrap().name(), "second");
558 }
559
560 #[test]
561 fn resolve_empty_routing_returns_empty_and_warns_once() {
562 // Priority missing from the table → empty vec, one warn.
563 // Resolving the same priority a second time → still empty
564 // vec, warn is deduped.
565 let reg = NotifierRegistry::new(NotificationRouting::default());
566 assert!(reg.resolve(EventPriority::Urgent).is_empty());
567 assert_eq!(reg.warned_count(), 1);
568 assert!(reg.resolve(EventPriority::Urgent).is_empty());
569 assert_eq!(reg.warned_count(), 1, "same-priority miss must dedup");
570
571 // Different priority → second warn key.
572 assert!(reg.resolve(EventPriority::Warning).is_empty());
573 assert_eq!(reg.warned_count(), 2);
574 }
575
576 #[test]
577 fn resolve_returns_only_registered_names() {
578 // Routing table names two plugins; only one is registered.
579 // Registered subset is returned; missing name fires a warn.
580 let mut routing = HashMap::new();
581 routing.insert(
582 EventPriority::Urgent,
583 vec!["stdout".to_string(), "ntfy".to_string()],
584 );
585 let (tn, _received) = TestNotifier::new("stdout");
586 let mut reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
587 reg.register("stdout", Arc::new(tn));
588
589 let resolved = reg.resolve(EventPriority::Urgent);
590 assert_eq!(resolved.len(), 1, "should return only the registered one");
591 assert_eq!(resolved[0].0, "stdout");
592 assert_eq!(reg.warned_count(), 1, "one warn for missing 'ntfy'");
593
594 // Second resolve of the same priority: same subset, same warn
595 // set size (the missing-name dedup kicks in).
596 let again = reg.resolve(EventPriority::Urgent);
597 assert_eq!(again.len(), 1);
598 assert_eq!(reg.warned_count(), 1);
599 }
600
601 #[test]
602 fn resolve_distinct_missing_names_are_warned_separately() {
603 // Two priorities each referencing a different missing plugin
604 // → two distinct warn keys.
605 let mut routing = HashMap::new();
606 routing.insert(EventPriority::Urgent, vec!["missing-a".to_string()]);
607 routing.insert(EventPriority::Warning, vec!["missing-b".to_string()]);
608 let reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
609
610 assert!(reg.resolve(EventPriority::Urgent).is_empty());
611 assert!(reg.resolve(EventPriority::Warning).is_empty());
612 assert_eq!(reg.warned_count(), 2);
613 }
614
615 #[test]
616 fn resolve_empty_list_warns_once() {
617 // A priority configured with an empty list is the same as
618 // "missing" from the engine's perspective — warn once, drop.
619 let mut routing = HashMap::new();
620 routing.insert(EventPriority::Warning, Vec::<String>::new());
621 let reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
622
623 assert!(reg.resolve(EventPriority::Warning).is_empty());
624 assert_eq!(reg.warned_count(), 1);
625 assert!(reg.resolve(EventPriority::Warning).is_empty());
626 assert_eq!(reg.warned_count(), 1);
627 }
628
629 #[test]
630 fn resolve_returns_plugins_in_routing_order() {
631 // The per-priority name list is a Vec — dispatch happens in
632 // declared order. Locking this in so Phase B's failure
633 // aggregation can rely on stable ordering.
634 let mut routing = HashMap::new();
635 routing.insert(
636 EventPriority::Info,
637 vec!["a".to_string(), "b".to_string(), "c".to_string()],
638 );
639 let (a, _) = TestNotifier::new("a");
640 let (b, _) = TestNotifier::new("b");
641 let (c, _) = TestNotifier::new("c");
642 let mut reg = NotifierRegistry::new(NotificationRouting::from_map(routing));
643 reg.register("a", Arc::new(a));
644 reg.register("b", Arc::new(b));
645 reg.register("c", Arc::new(c));
646
647 let resolved = reg.resolve(EventPriority::Info);
648 let names: Vec<&str> = resolved.iter().map(|(n, _)| n.as_str()).collect();
649 assert_eq!(names, vec!["a", "b", "c"]);
650 }
651
652 // ---- TestNotifier (directly) ----
653
654 #[tokio::test]
655 async fn test_notifier_records_payload() {
656 // Sanity-check the mock: send one payload, assert it landed
657 // in the shared vec. Phase B's engine tests will depend on
658 // this mechanism.
659 let (tn, received) = TestNotifier::new("test");
660 let payload = fake_payload(EventPriority::Urgent);
661 tn.send(&payload).await.unwrap();
662
663 let got = received.lock().unwrap();
664 assert_eq!(got.len(), 1);
665 assert_eq!(got[0].reaction_key, "ci-failed");
666 assert_eq!(got[0].priority, EventPriority::Urgent);
667 }
668}