1use std::{path::PathBuf, time::Duration};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::Error;
14
15#[non_exhaustive]
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub enum TriggerConfig {
19 Every {
21 #[serde(with = "duration_secs")]
23 interval: Duration,
24 },
25 OnFileChange {
27 path: PathBuf,
29 },
30}
31
32impl TriggerConfig {
33 const MIN_INTERVAL: Duration = Duration::from_secs(1);
35
36 #[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 #[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 #[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 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 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 #[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#[non_exhaustive]
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TriggerEntry {
165 pub name: String,
167 pub config: TriggerConfig,
169 pub message_template: String,
173}
174
175impl TriggerEntry {
176 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct TriggerSet {
214 entries: Vec<TriggerEntry>,
215}
216
217impl TriggerSet {
218 #[must_use]
220 pub const fn new() -> Self {
221 Self {
222 entries: Vec::new(),
223 }
224 }
225
226 pub fn push(&mut self, entry: TriggerEntry) -> Result<(), Error> {
233 entry.validate()?;
234 self.entries.push(entry);
235 Ok(())
236 }
237
238 pub fn iter(&self) -> impl Iterator<Item = &TriggerEntry> {
240 self.entries.iter()
241 }
242
243 #[must_use]
245 pub const fn len(&self) -> usize {
246 self.entries.len()
247 }
248
249 #[must_use]
251 pub const fn is_empty(&self) -> bool {
252 self.entries.is_empty()
253 }
254
255 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 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 fn from(entries: Vec<TriggerEntry>) -> Self {
310 Self::from_iter(entries)
311 }
312}
313
314impl<const N: usize> From<[TriggerEntry; N]> for TriggerSet {
315 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
345mod 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#[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 assert!(json.contains("120"), "Expected '120' in {json}");
592 }
593
594 #[test]
595 fn duration_secs_rejects_subsecond_via_serde() {
596 let json = r#"{"Every":{"interval":0.5}}"#;
599 let result = serde_json::from_str::<TriggerConfig>(json);
600 assert!(
601 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 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); assert_eq!(t.description(), "every(86400s)");
710 }
711
712 #[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}