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 #[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 #[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 #[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 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 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 #[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#[non_exhaustive]
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct TriggerEntry {
153 pub name: String,
155 pub config: TriggerConfig,
157 pub message_template: String,
161}
162
163impl TriggerEntry {
164 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct TriggerSet {
202 entries: Vec<TriggerEntry>,
203}
204
205impl TriggerSet {
206 #[must_use]
208 pub const fn new() -> Self {
209 Self {
210 entries: Vec::new(),
211 }
212 }
213
214 pub fn push(&mut self, entry: TriggerEntry) -> Result<(), Error> {
221 entry.validate()?;
222 self.entries.push(entry);
223 Ok(())
224 }
225
226 pub fn iter(&self) -> impl Iterator<Item = &TriggerEntry> {
228 self.entries.iter()
229 }
230
231 #[must_use]
233 pub const fn len(&self) -> usize {
234 self.entries.len()
235 }
236
237 #[must_use]
239 pub const fn is_empty(&self) -> bool {
240 self.entries.is_empty()
241 }
242
243 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 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 fn from(entries: Vec<TriggerEntry>) -> Self {
298 Self::from_iter(entries)
299 }
300}
301
302impl<const N: usize> From<[TriggerEntry; N]> for TriggerSet {
303 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
333mod 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 assert!(json.contains("120"), "Expected '120' in {json}");
578 }
579
580 #[test]
581 fn duration_secs_rejects_subsecond_via_serde() {
582 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 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); assert_eq!(t.description(), "every(86400s)");
695 }
696
697 #[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}