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