Skip to main content

agy_bridge/
triggers.rs

1//! Trigger configuration types for the Python Antigravity SDK.
2//!
3//! Provides [`TriggerConfig`], [`TriggerEntry`], and `TriggerSet` — the
4//! Rust-side configuration types that are serialized and passed to the
5//! Python SDK's trigger system (`@every`, `@on_file_change`). The actual
6//! file watching and scheduling is handled entirely by the Python SDK;
7//! this module contains no native Rust trigger implementations.
8
9use std::{path::PathBuf, time::Duration};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::Error;
14
15/// Configuration for a trigger that the SDK will run.
16#[non_exhaustive]
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum TriggerConfig {
19    /// Periodic trigger: fires every `interval`.
20    Every {
21        /// Interval between firings.
22        #[serde(with = "duration_secs")]
23        interval: Duration,
24    },
25    /// File-change trigger: fires when files change under `path`.
26    OnFileChange {
27        /// Directory to watch for changes.
28        path: PathBuf,
29    },
30}
31
32impl TriggerConfig {
33    /// Minimum allowed interval for an `Every` trigger.
34    const MIN_INTERVAL: Duration = Duration::from_secs(1);
35
36    /// Create an `Every` trigger from a number of seconds.
37    ///
38    /// # Panics
39    ///
40    /// Panics if `secs` is 0.
41    #[must_use]
42    pub const fn every_secs(secs: u64) -> Self {
43        assert!(secs >= 1, "trigger interval must be at least 1 second");
44        Self::Every {
45            interval: Duration::from_secs(secs),
46        }
47    }
48
49    /// Create an `Every` trigger with a specific Duration.
50    ///
51    /// # Panics
52    ///
53    /// Panics if the duration is less than 1 second.
54    #[must_use]
55    pub fn every(duration: Duration) -> Self {
56        assert!(
57            duration >= Self::MIN_INTERVAL,
58            "trigger interval must be at least 1 second, got {duration:?}"
59        );
60        Self::Every { interval: duration }
61    }
62
63    /// Create an `OnFileChange` trigger watching the given directory.
64    ///
65    /// # Panics
66    ///
67    /// Panics if `path` is empty, relative, or contains `..`.
68    #[must_use]
69    pub fn on_file_change(path: impl Into<PathBuf>) -> Self {
70        let path = path.into();
71        assert!(
72            !path.as_os_str().is_empty(),
73            "on_file_change path must not be empty"
74        );
75        assert!(
76            path.is_absolute(),
77            "on_file_change path must be absolute, got: {}",
78            path.display()
79        );
80        assert!(
81            !path
82                .components()
83                .any(|c| c == std::path::Component::ParentDir),
84            "on_file_change path must not contain '..', got: {}",
85            path.display()
86        );
87        Self::OnFileChange { path }
88    }
89
90    /// Fallible version of [`on_file_change`](Self::on_file_change).
91    ///
92    /// # Errors
93    ///
94    /// Returns [`Error::InvalidConfig`] if `path` is empty, relative,
95    /// or contains `..`.
96    pub fn try_on_file_change(path: impl Into<PathBuf>) -> Result<Self, Error> {
97        let path = path.into();
98        if path.as_os_str().is_empty() {
99            return Err(Error::InvalidConfig {
100                message: "on_file_change path must not be empty".to_owned(),
101            });
102        }
103        if !path.is_absolute() {
104            return Err(Error::InvalidConfig {
105                message: format!(
106                    "on_file_change path must be absolute, got: {}",
107                    path.display()
108                ),
109            });
110        }
111        if path
112            .components()
113            .any(|c| c == std::path::Component::ParentDir)
114        {
115            return Err(Error::InvalidConfig {
116                message: format!(
117                    "on_file_change path must not contain '..', got: {}",
118                    path.display()
119                ),
120            });
121        }
122        Ok(Self::OnFileChange { path })
123    }
124
125    /// Fallible version of [`every`](Self::every).
126    ///
127    /// # Errors
128    ///
129    /// Returns [`Error::InvalidConfig`] if the duration is less than 1 second.
130    pub fn try_every(duration: Duration) -> Result<Self, Error> {
131        if duration < Self::MIN_INTERVAL {
132            return Err(Error::InvalidConfig {
133                message: format!("trigger interval must be at least 1 second, got {duration:?}"),
134            });
135        }
136        Ok(Self::Every { interval: duration })
137    }
138
139    /// Human-readable description for logging.
140    #[must_use]
141    pub fn description(&self) -> String {
142        match self {
143            Self::Every { interval } => format!("every({}s)", interval.as_secs()),
144            Self::OnFileChange { path } => format!("on_file_change({})", path.display()),
145        }
146    }
147}
148
149/// A named trigger attached to an agent.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct TriggerEntry {
152    /// Descriptive name for the trigger (e.g. `"poll_threads"`).
153    pub name: String,
154    /// The trigger configuration.
155    pub config: TriggerConfig,
156    /// Message template sent to the agent when the trigger fires.
157    /// For `OnFileChange`, the placeholder `{changes}` is replaced with
158    /// the list of changed files.
159    pub message_template: String,
160}
161
162impl TriggerEntry {
163    /// Validate that the entry has non-empty name and `message_template`.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`Error::InvalidConfig`] if the name or
168    /// `message_template` is empty.
169    pub fn validate(&self) -> Result<(), Error> {
170        if self.name.trim().is_empty() {
171            return Err(Error::InvalidConfig {
172                message: "TriggerEntry name must not be empty".to_owned(),
173            });
174        }
175        if self.message_template.trim().is_empty() {
176            return Err(Error::InvalidConfig {
177                message: format!("TriggerEntry '{}' has an empty message_template", self.name),
178            });
179        }
180        Ok(())
181    }
182}
183
184/// An ordered list of triggers to attach to an agent.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct TriggerSet {
187    entries: Vec<TriggerEntry>,
188}
189
190impl TriggerSet {
191    /// Create an empty trigger set.
192    #[must_use]
193    pub const fn new() -> Self {
194        Self {
195            entries: Vec::new(),
196        }
197    }
198
199    /// Add a trigger entry.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`Error::InvalidConfig`] if the entry fails validation
204    /// (empty name or `message_template`).
205    pub fn push(&mut self, entry: TriggerEntry) -> Result<(), Error> {
206        entry.validate()?;
207        self.entries.push(entry);
208        Ok(())
209    }
210
211    /// Iterate over trigger entries.
212    pub fn iter(&self) -> impl Iterator<Item = &TriggerEntry> {
213        self.entries.iter()
214    }
215
216    /// Number of triggers.
217    #[must_use]
218    pub const fn len(&self) -> usize {
219        self.entries.len()
220    }
221
222    /// Whether the set is empty.
223    #[must_use]
224    pub const fn is_empty(&self) -> bool {
225        self.entries.is_empty()
226    }
227}
228
229impl From<TriggerSet> for Vec<TriggerEntry> {
230    fn from(set: TriggerSet) -> Self {
231        set.entries
232    }
233}
234
235impl From<&TriggerSet> for Vec<TriggerEntry> {
236    fn from(set: &TriggerSet) -> Self {
237        set.entries.clone()
238    }
239}
240
241impl FromIterator<TriggerEntry> for TriggerSet {
242    fn from_iter<T: IntoIterator<Item = TriggerEntry>>(iter: T) -> Self {
243        let mut set = Self::new();
244        for entry in iter {
245            set.push(entry)
246                .expect("TriggerSet::from_iter: invalid trigger entry");
247        }
248        set
249    }
250}
251
252impl From<Vec<TriggerEntry>> for TriggerSet {
253    fn from(entries: Vec<TriggerEntry>) -> Self {
254        Self::from_iter(entries)
255    }
256}
257
258impl<const N: usize> From<[TriggerEntry; N]> for TriggerSet {
259    fn from(entries: [TriggerEntry; N]) -> Self {
260        Self::from_iter(entries)
261    }
262}
263
264impl IntoIterator for TriggerSet {
265    type Item = TriggerEntry;
266    type IntoIter = std::vec::IntoIter<TriggerEntry>;
267
268    fn into_iter(self) -> Self::IntoIter {
269        self.entries.into_iter()
270    }
271}
272
273impl<'a> IntoIterator for &'a TriggerSet {
274    type Item = &'a TriggerEntry;
275    type IntoIter = std::slice::Iter<'a, TriggerEntry>;
276
277    fn into_iter(self) -> Self::IntoIter {
278        self.entries.iter()
279    }
280}
281
282/// Serde helper: serialize/deserialize [`Duration`] as fractional seconds (`f64`).
283///
284/// This preserves sub-second precision (e.g. `Duration::from_millis(1500)` →
285/// `1.5`) while remaining human-readable.
286mod duration_secs {
287    use std::time::Duration;
288
289    use serde::{Deserialize, Deserializer, Serializer};
290
291    pub fn serialize<S: Serializer>(d: &Duration, ser: S) -> Result<S::Ok, S::Error> {
292        ser.serialize_f64(d.as_secs_f64())
293    }
294
295    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
296        let secs = f64::deserialize(de)?;
297        if secs < 0.0 {
298            return Err(serde::de::Error::custom("duration must not be negative"));
299        }
300        Ok(Duration::from_secs_f64(secs))
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn every_trigger_description() {
310        let t = TriggerConfig::every_secs(30);
311        assert_eq!(t.description(), "every(30s)");
312    }
313
314    #[test]
315    fn on_file_change_trigger_description() {
316        let t = TriggerConfig::on_file_change("/workspace/threads");
317        assert_eq!(t.description(), "on_file_change(/workspace/threads)");
318    }
319
320    #[test]
321    fn every_fires_at_expected_interval() {
322        let t = TriggerConfig::every_secs(60);
323        match t {
324            TriggerConfig::Every { interval } => {
325                assert_eq!(interval, Duration::from_mins(1));
326            }
327            TriggerConfig::OnFileChange { .. } => {
328                panic!("Expected Every trigger");
329            }
330        }
331    }
332
333    #[test]
334    fn on_file_change_detects_path() {
335        let t = TriggerConfig::on_file_change("/workspace/sessions/bug123/threads");
336        match t {
337            TriggerConfig::OnFileChange { path } => {
338                assert_eq!(path, PathBuf::from("/workspace/sessions/bug123/threads"));
339            }
340            TriggerConfig::Every { .. } => {
341                panic!("Expected OnFileChange trigger");
342            }
343        }
344    }
345
346    #[test]
347    fn trigger_config_serde_roundtrip() {
348        let configs = vec![
349            TriggerConfig::every_secs(10),
350            TriggerConfig::on_file_change("/tmp/watch"),
351        ];
352        for config in &configs {
353            let json = serde_json::to_string(config).expect("serialize");
354            let parsed: TriggerConfig = serde_json::from_str(&json).expect("deserialize");
355            assert_eq!(&parsed, config);
356        }
357    }
358
359    #[test]
360    fn trigger_set_operations() {
361        let mut set = TriggerSet::new();
362        assert!(set.is_empty());
363
364        set.push(TriggerEntry {
365            name: "poll_threads".to_owned(),
366            config: TriggerConfig::every_secs(30),
367            message_template: "Check threads for updates".to_owned(),
368        })
369        .unwrap();
370        set.push(TriggerEntry {
371            name: "watch_threads".to_owned(),
372            config: TriggerConfig::on_file_change("/workspace/threads"),
373            message_template: "New files in threads: {changes}".to_owned(),
374        })
375        .unwrap();
376
377        assert_eq!(set.len(), 2);
378        let names: Vec<&str> = set.iter().map(|e| e.name.as_str()).collect();
379        assert_eq!(names, vec!["poll_threads", "watch_threads"]);
380    }
381
382    #[test]
383    fn trigger_entry_serde_roundtrip() {
384        let entry = TriggerEntry {
385            name: "poll".to_owned(),
386            config: TriggerConfig::every_secs(15),
387            message_template: "time to poll".to_owned(),
388        };
389        let json = serde_json::to_string(&entry).expect("serialize");
390        let parsed: TriggerEntry = serde_json::from_str(&json).expect("deserialize");
391        assert_eq!(parsed.name, entry.name);
392        assert_eq!(parsed.config, entry.config);
393        assert_eq!(parsed.message_template, entry.message_template);
394    }
395
396    #[test]
397    fn trigger_set_serde_roundtrip() {
398        let mut set = TriggerSet::new();
399        set.push(TriggerEntry {
400            name: "poll".to_owned(),
401            config: TriggerConfig::every_secs(60),
402            message_template: "poll now".to_owned(),
403        })
404        .unwrap();
405        set.push(TriggerEntry {
406            name: "watch".to_owned(),
407            config: TriggerConfig::on_file_change("/tmp"),
408            message_template: "files changed: {changes}".to_owned(),
409        })
410        .unwrap();
411        let json = serde_json::to_string(&set).expect("serialize");
412        let parsed: TriggerSet = serde_json::from_str(&json).expect("deserialize");
413        assert_eq!(parsed.len(), 2);
414        let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
415        assert_eq!(names, vec!["poll", "watch"]);
416    }
417
418    #[test]
419    fn trigger_set_from_conversions() {
420        let mut set = TriggerSet::new();
421        set.push(TriggerEntry {
422            name: "poll".to_owned(),
423            config: TriggerConfig::every_secs(60),
424            message_template: "poll now".to_owned(),
425        })
426        .unwrap();
427
428        let vec_from_owned: Vec<TriggerEntry> = Vec::from(set.clone());
429        assert_eq!(vec_from_owned.len(), 1);
430        assert_eq!(vec_from_owned[0].name, "poll");
431
432        let vec_from_ref: Vec<TriggerEntry> = Vec::from(&set);
433        assert_eq!(vec_from_ref.len(), 1);
434        assert_eq!(vec_from_ref[0].name, "poll");
435
436        let entry = TriggerEntry {
437            name: "poll".to_owned(),
438            config: TriggerConfig::every_secs(60),
439            message_template: "poll now".to_owned(),
440        };
441        let set_from_arr = TriggerSet::from([entry.clone()]);
442        assert_eq!(set_from_arr.len(), 1);
443
444        let set_from_vec = TriggerSet::from(vec![entry]);
445        assert_eq!(set_from_vec.len(), 1);
446    }
447
448    #[test]
449    fn trigger_set_default_is_empty() {
450        let set = TriggerSet::default();
451        assert!(set.is_empty());
452        assert_eq!(set.len(), 0);
453    }
454
455    #[test]
456    #[should_panic(expected = "trigger interval must be at least 1 second")]
457    fn every_trigger_zero_seconds_panics() {
458        eprintln!("{:?}", TriggerConfig::every_secs(0));
459    }
460
461    #[test]
462    #[should_panic(expected = "trigger interval must be at least 1 second")]
463    fn every_trigger_sub_second_panics() {
464        eprintln!("{:?}", TriggerConfig::every(Duration::from_millis(500)));
465    }
466
467    #[test]
468    fn duration_secs_serializes_as_number() {
469        let config = TriggerConfig::every_secs(120);
470        let json = serde_json::to_string(&config).expect("serialize");
471        // The interval should serialize as a number
472        assert!(json.contains("120"), "Expected '120' in {json}");
473    }
474
475    #[test]
476    fn duration_secs_preserves_subsecond_via_serde() {
477        // Construct via serde to bypass the constructor validation
478        let json = r#"{"Every":{"interval":1.5}}"#;
479        let parsed: TriggerConfig = serde_json::from_str(json).expect("deserialize");
480        match &parsed {
481            TriggerConfig::Every { interval } => {
482                assert_eq!(*interval, Duration::from_millis(1500));
483            }
484            TriggerConfig::OnFileChange { .. } => panic!("Expected Every, got OnFileChange"),
485        }
486        // Re-serialize should preserve 1.5
487        let reserialized = serde_json::to_string(&parsed).expect("serialize");
488        assert!(
489            reserialized.contains("1.5"),
490            "Sub-second duration should round-trip, got {reserialized}"
491        );
492    }
493
494    #[test]
495    #[should_panic(expected = "on_file_change path must not be empty")]
496    fn on_file_change_empty_path_panics() {
497        eprintln!("{:?}", TriggerConfig::on_file_change(""));
498    }
499
500    #[test]
501    #[should_panic(expected = "on_file_change path must be absolute")]
502    fn on_file_change_relative_path_panics() {
503        eprintln!("{:?}", TriggerConfig::on_file_change("relative/path"));
504    }
505
506    #[test]
507    #[should_panic(expected = "on_file_change path must not contain '..'")]
508    fn on_file_change_parent_traversal_panics() {
509        eprintln!(
510            "{:?}",
511            TriggerConfig::on_file_change("/workspace/../etc/passwd")
512        );
513    }
514
515    #[test]
516    fn trigger_entry_validate_empty_name() {
517        let entry = TriggerEntry {
518            name: "  ".to_owned(),
519            config: TriggerConfig::every_secs(10),
520            message_template: "msg".to_owned(),
521        };
522        assert!(entry.validate().is_err());
523    }
524
525    #[test]
526    fn trigger_entry_validate_empty_template() {
527        let entry = TriggerEntry {
528            name: "poll".to_owned(),
529            config: TriggerConfig::every_secs(10),
530            message_template: "  ".to_owned(),
531        };
532        assert!(entry.validate().is_err());
533    }
534
535    #[test]
536    fn trigger_entry_validate_ok() {
537        let entry = TriggerEntry {
538            name: "poll".to_owned(),
539            config: TriggerConfig::every_secs(10),
540            message_template: "poll now".to_owned(),
541        };
542        assert!(entry.validate().is_ok());
543    }
544
545    #[test]
546    fn trigger_config_equality() {
547        assert_eq!(TriggerConfig::every_secs(30), TriggerConfig::every_secs(30));
548        assert_ne!(TriggerConfig::every_secs(30), TriggerConfig::every_secs(60));
549        assert_ne!(
550            TriggerConfig::every_secs(30),
551            TriggerConfig::on_file_change("/tmp")
552        );
553        assert_eq!(
554            TriggerConfig::on_file_change("/a"),
555            TriggerConfig::on_file_change("/a")
556        );
557        assert_ne!(
558            TriggerConfig::on_file_change("/a"),
559            TriggerConfig::on_file_change("/b")
560        );
561    }
562
563    #[test]
564    fn trigger_large_interval() {
565        let t = TriggerConfig::every_secs(86400); // 24h
566        assert_eq!(t.description(), "every(86400s)");
567    }
568
569    // ── Fallible constructor tests ───────────────────────────────────────
570
571    #[test]
572    fn try_on_file_change_ok() {
573        let t = TriggerConfig::try_on_file_change("/workspace/threads").unwrap();
574        match t {
575            TriggerConfig::OnFileChange { path } => {
576                assert_eq!(path, PathBuf::from("/workspace/threads"));
577            }
578            TriggerConfig::Every { .. } => panic!("Expected OnFileChange"),
579        }
580    }
581
582    #[test]
583    fn try_on_file_change_empty_is_err() {
584        assert!(TriggerConfig::try_on_file_change("").is_err());
585    }
586
587    #[test]
588    fn try_on_file_change_relative_is_err() {
589        assert!(TriggerConfig::try_on_file_change("relative/path").is_err());
590    }
591
592    #[test]
593    fn try_on_file_change_parent_dir_is_err() {
594        assert!(TriggerConfig::try_on_file_change("/workspace/../etc/passwd").is_err());
595    }
596
597    #[test]
598    fn try_every_ok() {
599        let t = TriggerConfig::try_every(Duration::from_secs(5)).unwrap();
600        match t {
601            TriggerConfig::Every { interval } => {
602                assert_eq!(interval, Duration::from_secs(5));
603            }
604            TriggerConfig::OnFileChange { .. } => panic!("Expected Every"),
605        }
606    }
607
608    #[test]
609    fn try_every_sub_second_is_err() {
610        assert!(TriggerConfig::try_every(Duration::from_millis(500)).is_err());
611    }
612}