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#[non_exhaustive]
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct TriggerEntry {
153    /// Descriptive name for the trigger (e.g. `"poll_threads"`).
154    pub name: String,
155    /// The trigger configuration.
156    pub config: TriggerConfig,
157    /// Message template sent to the agent when the trigger fires.
158    /// For `OnFileChange`, the placeholder `{changes}` is replaced with
159    /// the list of changed files.
160    pub message_template: String,
161}
162
163impl TriggerEntry {
164    /// Create a new trigger entry.
165    #[must_use]
166    pub fn new(
167        name: impl Into<String>,
168        config: TriggerConfig,
169        message_template: impl Into<String>,
170    ) -> Self {
171        Self {
172            name: name.into(),
173            config,
174            message_template: message_template.into(),
175        }
176    }
177
178    /// Validate that the entry has non-empty name and `message_template`.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`Error::InvalidConfig`] if the name or
183    /// `message_template` is empty.
184    pub fn validate(&self) -> Result<(), Error> {
185        if self.name.trim().is_empty() {
186            return Err(Error::InvalidConfig {
187                message: "TriggerEntry name must not be empty".to_owned(),
188            });
189        }
190        if self.message_template.trim().is_empty() {
191            return Err(Error::InvalidConfig {
192                message: format!("TriggerEntry '{}' has an empty message_template", self.name),
193            });
194        }
195        Ok(())
196    }
197}
198
199/// An ordered list of triggers to attach to an agent.
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct TriggerSet {
202    entries: Vec<TriggerEntry>,
203}
204
205impl TriggerSet {
206    /// Create an empty trigger set.
207    #[must_use]
208    pub const fn new() -> Self {
209        Self {
210            entries: Vec::new(),
211        }
212    }
213
214    /// Add a trigger entry.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`Error::InvalidConfig`] if the entry fails validation
219    /// (empty name or `message_template`).
220    pub fn push(&mut self, entry: TriggerEntry) -> Result<(), Error> {
221        entry.validate()?;
222        self.entries.push(entry);
223        Ok(())
224    }
225
226    /// Iterate over trigger entries.
227    pub fn iter(&self) -> impl Iterator<Item = &TriggerEntry> {
228        self.entries.iter()
229    }
230
231    /// Number of triggers.
232    #[must_use]
233    pub const fn len(&self) -> usize {
234        self.entries.len()
235    }
236
237    /// Whether the set is empty.
238    #[must_use]
239    pub const fn is_empty(&self) -> bool {
240        self.entries.is_empty()
241    }
242
243    /// Fallible version of [`FromIterator`] — validates each entry
244    /// and returns the first error instead of panicking.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`Error::InvalidConfig`] if any entry has an empty name
249    /// or `message_template`.
250    pub fn try_from_iter(iter: impl IntoIterator<Item = TriggerEntry>) -> Result<Self, Error> {
251        let mut set = Self::new();
252        for entry in iter {
253            set.push(entry)?;
254        }
255        Ok(set)
256    }
257}
258
259impl From<TriggerSet> for Vec<TriggerEntry> {
260    fn from(set: TriggerSet) -> Self {
261        set.entries
262    }
263}
264
265impl From<&TriggerSet> for Vec<TriggerEntry> {
266    fn from(set: &TriggerSet) -> Self {
267        set.entries.clone()
268    }
269}
270
271impl FromIterator<TriggerEntry> for TriggerSet {
272    /// Create a `TriggerSet` from an iterator of trigger entries,
273    /// panic-validating each one.
274    ///
275    /// # Panics
276    ///
277    /// Panics if any entry fails validation (empty name or
278    /// `message_template`). Use [`TriggerSet::try_from_iter`] for a
279    /// fallible alternative.
280    fn from_iter<T: IntoIterator<Item = TriggerEntry>>(iter: T) -> Self {
281        let mut set = Self::new();
282        for entry in iter {
283            set.push(entry)
284                .expect("TriggerSet::from_iter: invalid trigger entry");
285        }
286        set
287    }
288}
289
290impl From<Vec<TriggerEntry>> for TriggerSet {
291    /// Convert a `Vec<TriggerEntry>` into a `TriggerSet`.
292    ///
293    /// # Panics
294    ///
295    /// Panics if any entry fails validation (empty name or
296    /// `message_template`). Prefer [`TriggerSet::try_from_iter`] for fallible conversion.
297    fn from(entries: Vec<TriggerEntry>) -> Self {
298        Self::from_iter(entries)
299    }
300}
301
302impl<const N: usize> From<[TriggerEntry; N]> for TriggerSet {
303    /// Create a `TriggerSet` from a fixed-size array, panic-validating
304    /// each entry.
305    ///
306    /// # Panics
307    ///
308    /// Panics if any entry fails validation (empty name or
309    /// `message_template`). Prefer [`TriggerSet::try_from_iter`] for fallible conversion.
310    fn from(entries: [TriggerEntry; N]) -> Self {
311        Self::from_iter(entries)
312    }
313}
314
315impl IntoIterator for TriggerSet {
316    type Item = TriggerEntry;
317    type IntoIter = std::vec::IntoIter<TriggerEntry>;
318
319    fn into_iter(self) -> Self::IntoIter {
320        self.entries.into_iter()
321    }
322}
323
324impl<'a> IntoIterator for &'a TriggerSet {
325    type Item = &'a TriggerEntry;
326    type IntoIter = std::slice::Iter<'a, TriggerEntry>;
327
328    fn into_iter(self) -> Self::IntoIter {
329        self.entries.iter()
330    }
331}
332
333/// Serde helper: serialize/deserialize [`Duration`] as fractional seconds (`f64`).
334///
335/// This preserves sub-second precision (e.g. `Duration::from_millis(1500)` →
336/// `1.5`) while remaining human-readable.
337mod duration_secs {
338    use std::time::Duration;
339
340    use serde::{Deserialize, Deserializer, Serializer};
341
342    pub fn serialize<S: Serializer>(d: &Duration, ser: S) -> Result<S::Ok, S::Error> {
343        ser.serialize_f64(d.as_secs_f64())
344    }
345
346    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
347        let secs = f64::deserialize(de)?;
348        if secs < 0.0 {
349            return Err(serde::de::Error::custom("duration must not be negative"));
350        }
351        if secs < 1.0 {
352            return Err(serde::de::Error::custom(
353                "trigger interval must be at least 1 second",
354            ));
355        }
356        Ok(Duration::from_secs_f64(secs))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn every_trigger_description() {
366        let t = TriggerConfig::every_secs(30);
367        assert_eq!(t.description(), "every(30s)");
368    }
369
370    #[test]
371    fn on_file_change_trigger_description() {
372        let t = TriggerConfig::on_file_change("/workspace/threads");
373        assert_eq!(t.description(), "on_file_change(/workspace/threads)");
374    }
375
376    #[test]
377    fn every_fires_at_expected_interval() {
378        let t = TriggerConfig::every_secs(60);
379        match t {
380            TriggerConfig::Every { interval } => {
381                assert_eq!(interval, Duration::from_mins(1));
382            }
383            TriggerConfig::OnFileChange { .. } => {
384                panic!("Expected Every trigger");
385            }
386        }
387    }
388
389    #[test]
390    fn on_file_change_detects_path() {
391        let t = TriggerConfig::on_file_change("/workspace/sessions/bug123/threads");
392        match t {
393            TriggerConfig::OnFileChange { path } => {
394                assert_eq!(path, PathBuf::from("/workspace/sessions/bug123/threads"));
395            }
396            TriggerConfig::Every { .. } => {
397                panic!("Expected OnFileChange trigger");
398            }
399        }
400    }
401
402    #[test]
403    fn trigger_config_serde_roundtrip() {
404        let configs = vec![
405            TriggerConfig::every_secs(10),
406            TriggerConfig::on_file_change("/tmp/watch"),
407        ];
408        for config in &configs {
409            let json = serde_json::to_string(config).expect("serialize");
410            let parsed: TriggerConfig = serde_json::from_str(&json).expect("deserialize");
411            assert_eq!(&parsed, config);
412        }
413    }
414
415    #[test]
416    fn trigger_set_operations() {
417        let mut set = TriggerSet::new();
418        assert!(set.is_empty());
419
420        set.push(TriggerEntry {
421            name: "poll_threads".to_owned(),
422            config: TriggerConfig::every_secs(30),
423            message_template: "Check threads for updates".to_owned(),
424        })
425        .unwrap();
426        set.push(TriggerEntry {
427            name: "watch_threads".to_owned(),
428            config: TriggerConfig::on_file_change("/workspace/threads"),
429            message_template: "New files in threads: {changes}".to_owned(),
430        })
431        .unwrap();
432
433        assert_eq!(set.len(), 2);
434        let names: Vec<&str> = set.iter().map(|e| e.name.as_str()).collect();
435        assert_eq!(names, vec!["poll_threads", "watch_threads"]);
436    }
437
438    #[test]
439    fn trigger_entry_serde_roundtrip() {
440        let entry = TriggerEntry {
441            name: "poll".to_owned(),
442            config: TriggerConfig::every_secs(15),
443            message_template: "time to poll".to_owned(),
444        };
445        let json = serde_json::to_string(&entry).expect("serialize");
446        let parsed: TriggerEntry = serde_json::from_str(&json).expect("deserialize");
447        assert_eq!(parsed.name, entry.name);
448        assert_eq!(parsed.config, entry.config);
449        assert_eq!(parsed.message_template, entry.message_template);
450    }
451
452    #[test]
453    fn trigger_set_serde_roundtrip() {
454        let mut set = TriggerSet::new();
455        set.push(TriggerEntry {
456            name: "poll".to_owned(),
457            config: TriggerConfig::every_secs(60),
458            message_template: "poll now".to_owned(),
459        })
460        .unwrap();
461        set.push(TriggerEntry {
462            name: "watch".to_owned(),
463            config: TriggerConfig::on_file_change("/tmp"),
464            message_template: "files changed: {changes}".to_owned(),
465        })
466        .unwrap();
467        let json = serde_json::to_string(&set).expect("serialize");
468        let parsed: TriggerSet = serde_json::from_str(&json).expect("deserialize");
469        assert_eq!(parsed.len(), 2);
470        let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
471        assert_eq!(names, vec!["poll", "watch"]);
472    }
473
474    #[test]
475    fn trigger_set_from_conversions() {
476        let mut set = TriggerSet::new();
477        set.push(TriggerEntry {
478            name: "poll".to_owned(),
479            config: TriggerConfig::every_secs(60),
480            message_template: "poll now".to_owned(),
481        })
482        .unwrap();
483
484        let vec_from_owned: Vec<TriggerEntry> = Vec::from(set.clone());
485        assert_eq!(vec_from_owned.len(), 1);
486        assert_eq!(vec_from_owned[0].name, "poll");
487
488        let vec_from_ref: Vec<TriggerEntry> = Vec::from(&set);
489        assert_eq!(vec_from_ref.len(), 1);
490        assert_eq!(vec_from_ref[0].name, "poll");
491
492        let entry = TriggerEntry {
493            name: "poll".to_owned(),
494            config: TriggerConfig::every_secs(60),
495            message_template: "poll now".to_owned(),
496        };
497        let set_from_arr = TriggerSet::from([entry.clone()]);
498        assert_eq!(set_from_arr.len(), 1);
499
500        let set_from_vec = TriggerSet::from(vec![entry]);
501        assert_eq!(set_from_vec.len(), 1);
502    }
503
504    #[test]
505    fn try_from_vec_valid_entries() {
506        let entries = vec![TriggerEntry {
507            name: "poll".to_owned(),
508            config: TriggerConfig::every_secs(60),
509            message_template: "poll now".to_owned(),
510        }];
511        let set = TriggerSet::try_from_iter(entries).expect("valid entries");
512        assert_eq!(set.len(), 1);
513    }
514
515    #[test]
516    fn try_from_iter_array_valid_entries() {
517        let entry = TriggerEntry {
518            name: "poll".to_owned(),
519            config: TriggerConfig::every_secs(60),
520            message_template: "poll now".to_owned(),
521        };
522        let set = TriggerSet::try_from_iter([entry]).expect("valid entries");
523        assert_eq!(set.len(), 1);
524    }
525
526    #[test]
527    fn try_from_iter_array_invalid_entry_is_err() {
528        let entry = TriggerEntry {
529            name: "poll".to_owned(),
530            config: TriggerConfig::every_secs(10),
531            message_template: "  ".to_owned(),
532        };
533        assert!(TriggerSet::try_from_iter([entry]).is_err());
534    }
535
536    #[test]
537    fn try_from_iter_invalid_entry_is_err() {
538        let entries = vec![
539            TriggerEntry {
540                name: "poll".to_owned(),
541                config: TriggerConfig::every_secs(60),
542                message_template: "poll now".to_owned(),
543            },
544            TriggerEntry {
545                name: String::new(),
546                config: TriggerConfig::every_secs(10),
547                message_template: "msg".to_owned(),
548            },
549        ];
550        assert!(TriggerSet::try_from_iter(entries).is_err());
551    }
552
553    #[test]
554    fn trigger_set_default_is_empty() {
555        let set = TriggerSet::default();
556        assert!(set.is_empty());
557        assert_eq!(set.len(), 0);
558    }
559
560    #[test]
561    #[should_panic(expected = "trigger interval must be at least 1 second")]
562    fn every_trigger_zero_seconds_panics() {
563        eprintln!("{:?}", TriggerConfig::every_secs(0));
564    }
565
566    #[test]
567    #[should_panic(expected = "trigger interval must be at least 1 second")]
568    fn every_trigger_sub_second_panics() {
569        eprintln!("{:?}", TriggerConfig::every(Duration::from_millis(500)));
570    }
571
572    #[test]
573    fn duration_secs_serializes_as_number() {
574        let config = TriggerConfig::every_secs(120);
575        let json = serde_json::to_string(&config).expect("serialize");
576        // The interval should serialize as a number
577        assert!(json.contains("120"), "Expected '120' in {json}");
578    }
579
580    #[test]
581    fn duration_secs_rejects_subsecond_via_serde() {
582        // Sub-second intervals should be rejected during deserialization,
583        // matching the constructor validation.
584        let json = r#"{"Every":{"interval":0.5}}"#;
585        let result = serde_json::from_str::<TriggerConfig>(json);
586        assert!(
587            result.is_err(),
588            "Sub-second interval should be rejected during deserialization"
589        );
590    }
591
592    #[test]
593    fn duration_secs_accepts_exactly_one_second() {
594        let json = r#"{"Every":{"interval":1.0}}"#;
595        let parsed: TriggerConfig = serde_json::from_str(json).expect("deserialize");
596        match &parsed {
597            TriggerConfig::Every { interval } => {
598                assert_eq!(*interval, Duration::from_secs(1));
599            }
600            TriggerConfig::OnFileChange { .. } => panic!("Expected Every, got OnFileChange"),
601        }
602    }
603
604    #[test]
605    fn duration_secs_preserves_supersecond_fractional() {
606        // Fractional values ≥1s (e.g. 1.5s) should still work.
607        let json = r#"{"Every":{"interval":1.5}}"#;
608        let parsed: TriggerConfig = serde_json::from_str(json).expect("deserialize");
609        match &parsed {
610            TriggerConfig::Every { interval } => {
611                assert_eq!(*interval, Duration::from_millis(1500));
612            }
613            TriggerConfig::OnFileChange { .. } => panic!("Expected Every, got OnFileChange"),
614        }
615        let reserialized = serde_json::to_string(&parsed).expect("serialize");
616        assert!(
617            reserialized.contains("1.5"),
618            "Super-second fractional duration should round-trip, got {reserialized}"
619        );
620    }
621
622    #[test]
623    #[should_panic(expected = "on_file_change path must not be empty")]
624    fn on_file_change_empty_path_panics() {
625        eprintln!("{:?}", TriggerConfig::on_file_change(""));
626    }
627
628    #[test]
629    #[should_panic(expected = "on_file_change path must be absolute")]
630    fn on_file_change_relative_path_panics() {
631        eprintln!("{:?}", TriggerConfig::on_file_change("relative/path"));
632    }
633
634    #[test]
635    #[should_panic(expected = "on_file_change path must not contain '..'")]
636    fn on_file_change_parent_traversal_panics() {
637        eprintln!(
638            "{:?}",
639            TriggerConfig::on_file_change("/workspace/../etc/passwd")
640        );
641    }
642
643    #[test]
644    fn trigger_entry_validate_empty_name() {
645        let entry = TriggerEntry {
646            name: "  ".to_owned(),
647            config: TriggerConfig::every_secs(10),
648            message_template: "msg".to_owned(),
649        };
650        assert!(entry.validate().is_err());
651    }
652
653    #[test]
654    fn trigger_entry_validate_empty_template() {
655        let entry = TriggerEntry {
656            name: "poll".to_owned(),
657            config: TriggerConfig::every_secs(10),
658            message_template: "  ".to_owned(),
659        };
660        assert!(entry.validate().is_err());
661    }
662
663    #[test]
664    fn trigger_entry_validate_ok() {
665        let entry = TriggerEntry {
666            name: "poll".to_owned(),
667            config: TriggerConfig::every_secs(10),
668            message_template: "poll now".to_owned(),
669        };
670        assert!(entry.validate().is_ok());
671    }
672
673    #[test]
674    fn trigger_config_equality() {
675        assert_eq!(TriggerConfig::every_secs(30), TriggerConfig::every_secs(30));
676        assert_ne!(TriggerConfig::every_secs(30), TriggerConfig::every_secs(60));
677        assert_ne!(
678            TriggerConfig::every_secs(30),
679            TriggerConfig::on_file_change("/tmp")
680        );
681        assert_eq!(
682            TriggerConfig::on_file_change("/a"),
683            TriggerConfig::on_file_change("/a")
684        );
685        assert_ne!(
686            TriggerConfig::on_file_change("/a"),
687            TriggerConfig::on_file_change("/b")
688        );
689    }
690
691    #[test]
692    fn trigger_large_interval() {
693        let t = TriggerConfig::every_secs(86400); // 24h
694        assert_eq!(t.description(), "every(86400s)");
695    }
696
697    // ── Fallible constructor tests ───────────────────────────────────────
698
699    #[test]
700    fn try_on_file_change_ok() {
701        let t = TriggerConfig::try_on_file_change("/workspace/threads").unwrap();
702        match t {
703            TriggerConfig::OnFileChange { path } => {
704                assert_eq!(path, PathBuf::from("/workspace/threads"));
705            }
706            TriggerConfig::Every { .. } => panic!("Expected OnFileChange"),
707        }
708    }
709
710    #[test]
711    fn try_on_file_change_empty_is_err() {
712        assert!(TriggerConfig::try_on_file_change("").is_err());
713    }
714
715    #[test]
716    fn try_on_file_change_relative_is_err() {
717        assert!(TriggerConfig::try_on_file_change("relative/path").is_err());
718    }
719
720    #[test]
721    fn try_on_file_change_parent_dir_is_err() {
722        assert!(TriggerConfig::try_on_file_change("/workspace/../etc/passwd").is_err());
723    }
724
725    #[test]
726    fn try_every_ok() {
727        let t = TriggerConfig::try_every(Duration::from_secs(5)).unwrap();
728        match t {
729            TriggerConfig::Every { interval } => {
730                assert_eq!(interval, Duration::from_secs(5));
731            }
732            TriggerConfig::OnFileChange { .. } => panic!("Expected Every"),
733        }
734    }
735
736    #[test]
737    fn try_every_sub_second_is_err() {
738        assert!(TriggerConfig::try_every(Duration::from_millis(500)).is_err());
739    }
740}