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#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct TriggerEntry {
152 pub name: String,
154 pub config: TriggerConfig,
156 pub message_template: String,
160}
161
162impl TriggerEntry {
163 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct TriggerSet {
187 entries: Vec<TriggerEntry>,
188}
189
190impl TriggerSet {
191 #[must_use]
193 pub const fn new() -> Self {
194 Self {
195 entries: Vec::new(),
196 }
197 }
198
199 pub fn push(&mut self, entry: TriggerEntry) -> Result<(), Error> {
206 entry.validate()?;
207 self.entries.push(entry);
208 Ok(())
209 }
210
211 pub fn iter(&self) -> impl Iterator<Item = &TriggerEntry> {
213 self.entries.iter()
214 }
215
216 #[must_use]
218 pub const fn len(&self) -> usize {
219 self.entries.len()
220 }
221
222 #[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
282mod 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 assert!(json.contains("120"), "Expected '120' in {json}");
473 }
474
475 #[test]
476 fn duration_secs_preserves_subsecond_via_serde() {
477 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 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); assert_eq!(t.description(), "every(86400s)");
567 }
568
569 #[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}