Skip to main content

mur_common/hub/
trigger.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5// ─── DwellSpec ────────────────────────────────────────────────────────────────
6
7#[derive(Debug, Clone, PartialEq)]
8pub enum DwellSpec {
9    Seconds(f64),
10    UntilDone,
11    UntilAck,
12    UntilActive,
13    Lipsync,
14    UntilFocusOff,
15}
16
17impl<'de> Deserialize<'de> for DwellSpec {
18    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
19        struct Visitor;
20        impl<'de> serde::de::Visitor<'de> for Visitor {
21            type Value = DwellSpec;
22            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23                write!(
24                    f,
25                    "a number or one of until_done/until_ack/until_active/lipsync/until_focus_off"
26                )
27            }
28            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
29                Ok(DwellSpec::Seconds(v as f64))
30            }
31            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
32                Ok(DwellSpec::Seconds(v))
33            }
34            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
35                Ok(DwellSpec::Seconds(v as f64))
36            }
37            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
38                match v {
39                    "until_done" => Ok(DwellSpec::UntilDone),
40                    "until_ack" => Ok(DwellSpec::UntilAck),
41                    "until_active" => Ok(DwellSpec::UntilActive),
42                    "lipsync" => Ok(DwellSpec::Lipsync),
43                    "until_focus_off" => Ok(DwellSpec::UntilFocusOff),
44                    _ => Err(E::unknown_variant(
45                        v,
46                        &[
47                            "until_done",
48                            "until_ack",
49                            "until_active",
50                            "lipsync",
51                            "until_focus_off",
52                        ],
53                    )),
54                }
55            }
56        }
57        d.deserialize_any(Visitor)
58    }
59}
60
61impl Serialize for DwellSpec {
62    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
63        match self {
64            DwellSpec::Seconds(n) => s.serialize_f64(*n),
65            DwellSpec::UntilDone => s.serialize_str("until_done"),
66            DwellSpec::UntilAck => s.serialize_str("until_ack"),
67            DwellSpec::UntilActive => s.serialize_str("until_active"),
68            DwellSpec::Lipsync => s.serialize_str("lipsync"),
69            DwellSpec::UntilFocusOff => s.serialize_str("until_focus_off"),
70        }
71    }
72}
73
74// ─── ExpressionTrigger ───────────────────────────────────────────────────────
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ExpressionTrigger {
78    /// Event name to match (e.g. "companion.message.new").
79    pub on: String,
80    /// Expression ID to activate (e.g. "wave").
81    pub expression: String,
82    pub dwell_s: DwellSpec,
83    #[serde(default)]
84    pub bubble: bool,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub message: Option<String>,
87}
88
89// ─── TriggerSet ──────────────────────────────────────────────────────────────
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct TriggerSet {
93    pub triggers: Vec<ExpressionTrigger>,
94}
95
96// ─── Loaders ─────────────────────────────────────────────────────────────────
97
98const DEFAULT_YAML: &str = include_str!("triggers/default.yaml");
99
100pub fn default_triggers() -> Vec<ExpressionTrigger> {
101    serde_yaml_ng::from_str::<TriggerSet>(DEFAULT_YAML)
102        .expect("built-in default.yaml is always valid")
103        .triggers
104}
105
106/// Load per-agent trigger overrides; falls back to defaults if file is absent or invalid.
107pub fn load_triggers(agent_dir: &Path) -> Vec<ExpressionTrigger> {
108    let path = agent_dir.join("triggers.yaml");
109    if let Ok(text) = std::fs::read_to_string(&path)
110        && let Ok(ts) = serde_yaml_ng::from_str::<TriggerSet>(&text)
111    {
112        return ts.triggers;
113    }
114    default_triggers()
115}
116
117// ─── Tests ───────────────────────────────────────────────────────────────────
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn default_triggers_count() {
125        assert_eq!(default_triggers().len(), 10);
126    }
127
128    #[test]
129    fn dwell_spec_numeric() {
130        let t: TriggerSet =
131            serde_yaml_ng::from_str("triggers:\n  - { on: x, expression: idle, dwell_s: 3.5 }")
132                .unwrap();
133        assert_eq!(t.triggers[0].dwell_s, DwellSpec::Seconds(3.5));
134    }
135
136    #[test]
137    fn dwell_spec_until_ack() {
138        let t: TriggerSet = serde_yaml_ng::from_str(
139            "triggers:\n  - { on: x, expression: error, dwell_s: until_ack }",
140        )
141        .unwrap();
142        assert_eq!(t.triggers[0].dwell_s, DwellSpec::UntilAck);
143    }
144
145    #[test]
146    fn all_known_dwells_parse() {
147        let yaml = "triggers:
148  - { on: a, expression: idle, dwell_s: 2 }
149  - { on: b, expression: idle, dwell_s: until_done }
150  - { on: c, expression: idle, dwell_s: until_ack }
151  - { on: d, expression: idle, dwell_s: until_active }
152  - { on: e, expression: idle, dwell_s: lipsync }
153  - { on: f, expression: idle, dwell_s: until_focus_off }";
154        let ts: TriggerSet = serde_yaml_ng::from_str(yaml).unwrap();
155        assert_eq!(ts.triggers.len(), 6);
156    }
157}