1use std::cell::{Cell, RefCell};
8use std::collections::BTreeSet;
9use std::error::Error;
10use std::fmt;
11use std::fs;
12use std::future::Future;
13use std::path::{Path, PathBuf};
14use std::pin::Pin;
15use std::process::Command;
16use std::rc::Rc;
17use std::sync::mpsc;
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use futures_core::Stream;
21use notify::event::ModifyKind;
22use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
23
24use crate::async_driver::DriverCancel;
25use crate::environment::{
26 HttpRequest, HttpResponse, HttpStreamDriverEvent, HttpStreamHead, ProcessCommand,
27 ProcessResult, SseDriverEvent, SseEvent, SseRequest, WebSocketDriverEvent, WebSocketEvent,
28 WebSocketRequest, WebhookDriverEvent, WebhookEvent, WebhookRegistration,
29};
30use crate::node::Node;
31use crate::node::{NodeOpts, Pausable};
32use crate::operators::Operator;
33use crate::protocol::{AnyValue, GraphError, Message};
34
35pub fn of<T: Clone + 'static>(value: T) -> Operator<T> {
37 Operator::new("of", move |ctx| {
38 let out: AnyValue = Rc::new(value.clone());
39 ctx.down(vec![Message::Data(out), Message::Complete]);
40 })
41}
42
43pub fn from_iter<T: Clone + 'static>(items: impl IntoIterator<Item = T>) -> Operator<T> {
45 let values: Vec<T> = items.into_iter().collect();
46 Operator::new("fromIter", move |ctx| {
47 for value in &values {
48 let out: AnyValue = Rc::new(value.clone());
49 ctx.down(vec![Message::Data(out)]);
50 }
51 ctx.down(vec![Message::Complete]);
52 })
53}
54
55pub fn empty<T: 'static>() -> Operator<T> {
57 Operator::new("empty", |ctx| {
58 ctx.down(vec![Message::Complete]);
59 })
60}
61
62pub fn never<T: 'static>() -> Operator<T> {
64 Operator::new("never", |_| {})
65}
66
67pub fn throw_error<T: 'static>(err: impl Into<String>) -> Operator<T> {
69 let err = err.into();
70 Operator::new("throwError", move |ctx| {
71 ctx.down(vec![Message::Error(err.clone().into())]);
72 })
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum SyncValueFromError {
78 CompleteWithoutData,
80 Error(String),
82 Pending,
84 Teardown,
86 TooManyValues,
88 ValueTypeMismatch,
90}
91
92impl fmt::Display for SyncValueFromError {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match self {
95 Self::CompleteWithoutData => f.write_str("node completed before emitting DATA"),
96 Self::Error(error) => write!(f, "node emitted ERROR: {error}"),
97 Self::Pending => f.write_str("node did not synchronously emit a final value"),
98 Self::Teardown => f.write_str("node tore down before emitting a value result"),
99 Self::TooManyValues => f.write_str("node emitted more than one DATA value"),
100 Self::ValueTypeMismatch => f.write_str("node emitted DATA with an unexpected type"),
101 }
102 }
103}
104
105impl Error for SyncValueFromError {}
106
107pub fn first_sync_value_from<T>(node: &Node<T>) -> Result<T, SyncValueFromError>
114where
115 T: Clone + 'static,
116{
117 let state = Rc::new(RefCell::new(SyncValueCapture::<T>::default()));
118 let sink_state = state.clone();
119 let unsubscribe = node.subscribe(move |msg| {
120 sink_state.borrow_mut().record(msg);
121 });
122 unsubscribe();
123 let result = state.borrow_mut().first_result();
124 result
125}
126
127pub fn single_sync_value_from<T>(node: &Node<T>) -> Result<T, SyncValueFromError>
133where
134 T: Clone + 'static,
135{
136 let state = Rc::new(RefCell::new(SyncValueCapture::<T>::default()));
137 let sink_state = state.clone();
138 let unsubscribe = node.subscribe(move |msg| {
139 sink_state.borrow_mut().record(msg);
140 });
141 unsubscribe();
142 let result = state.borrow_mut().single_result();
143 result
144}
145
146struct SyncValueCapture<T> {
147 first: Option<T>,
148 value_count: usize,
149 error: Option<String>,
150 completed: bool,
151 torn_down: bool,
152 type_mismatch: bool,
153}
154
155impl<T> Default for SyncValueCapture<T> {
156 fn default() -> Self {
157 Self {
158 first: None,
159 value_count: 0,
160 error: None,
161 completed: false,
162 torn_down: false,
163 type_mismatch: false,
164 }
165 }
166}
167
168impl<T> SyncValueCapture<T>
169where
170 T: Clone + 'static,
171{
172 fn record(&mut self, msg: &Message<AnyValue>) {
173 match msg {
174 Message::Data(value) => match value.as_ref().downcast_ref::<T>() {
175 Some(value) => {
176 if self.value_count == 0 {
177 self.first = Some(value.clone());
178 }
179 self.value_count = self.value_count.saturating_add(1);
180 }
181 None => self.type_mismatch = true,
182 },
183 Message::Error(error) => {
184 if self.error.is_none() {
185 self.error = Some(error.to_string());
186 }
187 }
188 Message::Complete => {
189 self.completed = true;
190 }
191 Message::Teardown => {
192 self.torn_down = true;
193 }
194 Message::Start
195 | Message::Dirty
196 | Message::Resolved
197 | Message::Invalidate
198 | Message::Pause(_)
199 | Message::Resume(_)
200 | Message::Pull(_) => {}
201 }
202 }
203
204 fn first_result(&mut self) -> Result<T, SyncValueFromError> {
205 if self.type_mismatch {
206 return Err(SyncValueFromError::ValueTypeMismatch);
207 }
208 if let Some(error) = &self.error {
209 return Err(SyncValueFromError::Error(error.clone()));
210 }
211 if self.torn_down {
212 return Err(SyncValueFromError::Teardown);
213 }
214 if let Some(value) = self.first.take() {
215 return Ok(value);
216 }
217 if self.completed {
218 return Err(SyncValueFromError::CompleteWithoutData);
219 }
220 Err(SyncValueFromError::Pending)
221 }
222
223 fn single_result(&mut self) -> Result<T, SyncValueFromError> {
224 if self.type_mismatch {
225 return Err(SyncValueFromError::ValueTypeMismatch);
226 }
227 if let Some(error) = &self.error {
228 return Err(SyncValueFromError::Error(error.clone()));
229 }
230 if self.torn_down {
231 return Err(SyncValueFromError::Teardown);
232 }
233 if !self.completed {
234 return Err(SyncValueFromError::Pending);
235 }
236 match self.value_count {
237 0 => Err(SyncValueFromError::CompleteWithoutData),
238 1 => self
239 .first
240 .take()
241 .ok_or(SyncValueFromError::ValueTypeMismatch),
242 _ => Err(SyncValueFromError::TooManyValues),
243 }
244 }
245}
246
247pub fn run_process<I, S>(program: impl Into<String>, args: I) -> Operator<ProcessResult>
252where
253 I: IntoIterator<Item = S>,
254 S: Into<String>,
255{
256 run_process_with_options(ProcessCommand::new(program).args(args))
257}
258
259pub fn from_process<I, S>(program: impl Into<String>, args: I) -> Operator<ProcessResult>
261where
262 I: IntoIterator<Item = S>,
263 S: Into<String>,
264{
265 let command = ProcessCommand::new(program).args(args);
266 process_source("fromProcess", command)
267}
268
269pub fn run_process_with_options(command: ProcessCommand) -> Operator<ProcessResult> {
271 process_source("runProcess", command)
272}
273
274fn process_source(factory: &'static str, command: ProcessCommand) -> Operator<ProcessResult> {
275 assert!(
276 !command.program.is_empty(),
277 "{factory}: program must be a non-empty string"
278 );
279 Operator::with_opts(
280 factory,
281 NodeOpts {
282 pool: crate::dispatcher::PoolKind::Async,
283 pausable: Pausable::False,
284 ..NodeOpts::default()
285 },
286 move |ctx| {
287 let Some(driver) = ctx.environment().process_driver() else {
288 ctx.down(vec![Message::Error(
289 format!("{factory}: missing process driver").into(),
290 )]);
291 return;
292 };
293 let active = Rc::new(Cell::new(true));
294 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
295 let cleanup_active = active.clone();
296 let cleanup_cancel = cancel_slot.clone();
297 ctx.on_deactivation(move || {
298 cleanup_driver_work(&cleanup_active, &cleanup_cancel);
299 });
300 let out = ctx.defer();
301 let callback_active = active.clone();
302 let callback_cancel = cancel_slot.clone();
303 let cancel = driver.run(
304 command.clone(),
305 Box::new(move |result| {
306 if !callback_active.get() {
307 return;
308 }
309 cleanup_driver_work(&callback_active, &callback_cancel);
310 match result {
311 Ok(result) => {
312 out.down(vec![Message::Data(Rc::new(result)), Message::Complete]);
313 }
314 Err(error) => {
315 out.down(vec![Message::Error(error)]);
316 }
317 }
318 }),
319 );
320 install_driver_cancel(&active, &cancel_slot, cancel);
321 },
322 )
323}
324
325pub fn from_http(url: impl Into<String>) -> Operator<HttpResponse> {
327 from_http_with_options(HttpRequest::get(url))
328}
329
330pub fn from_http_with_options(request: HttpRequest) -> Operator<HttpResponse> {
332 assert!(!request.url.is_empty(), "fromHttp: url must be non-empty");
333 assert!(
334 !request.method.is_empty(),
335 "fromHttp: method must be non-empty"
336 );
337 Operator::with_opts(
338 "fromHttp",
339 NodeOpts {
340 pool: crate::dispatcher::PoolKind::Async,
341 pausable: Pausable::False,
342 ..NodeOpts::default()
343 },
344 move |ctx| {
345 let Some(driver) = ctx.environment().http_driver() else {
346 ctx.down(vec![Message::Error("fromHttp: missing http driver".into())]);
347 return;
348 };
349 let active = Rc::new(Cell::new(true));
350 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
351 let cleanup_active = active.clone();
352 let cleanup_cancel = cancel_slot.clone();
353 ctx.on_deactivation(move || {
354 cleanup_driver_work(&cleanup_active, &cleanup_cancel);
355 });
356 let out = ctx.defer();
357 let callback_active = active.clone();
358 let callback_cancel = cancel_slot.clone();
359 let cancel = driver.request(
360 request.clone(),
361 Box::new(move |result| {
362 if !callback_active.get() {
363 return;
364 }
365 cleanup_driver_work(&callback_active, &callback_cancel);
366 match result {
367 Ok(response) => {
368 out.down(vec![Message::Data(Rc::new(response)), Message::Complete]);
369 }
370 Err(error) => out.down(vec![Message::Error(error)]),
371 }
372 }),
373 );
374 install_driver_cancel(&active, &cancel_slot, cancel);
375 },
376 )
377}
378
379pub fn from_sse(url: impl Into<String>) -> Operator<SseEvent> {
381 from_sse_with_options(SseRequest::new(url))
382}
383
384pub fn from_sse_with_options(request: SseRequest) -> Operator<SseEvent> {
386 assert!(!request.url.is_empty(), "fromSSE: url must be non-empty");
387 Operator::with_opts(
388 "fromSSE",
389 NodeOpts {
390 pool: crate::dispatcher::PoolKind::Async,
391 pausable: Pausable::False,
392 ..NodeOpts::default()
393 },
394 move |ctx| {
395 let active = Rc::new(Cell::new(true));
396 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
397 let cleanup_active = active.clone();
398 let cleanup_cancel = cancel_slot.clone();
399 ctx.on_deactivation(move || {
400 cleanup_driver_work(&cleanup_active, &cleanup_cancel);
401 });
402 let out = ctx.defer();
403 if let Some(driver) = ctx.environment().sse_driver() {
404 let callback_active = active.clone();
405 let callback_cancel = cancel_slot.clone();
406 let callback = Rc::new(move |event| match event {
407 SseDriverEvent::Event(event) => {
408 if callback_active.get() {
409 out.down(vec![Message::Data(Rc::new(event))]);
410 }
411 }
412 SseDriverEvent::Error(error) => {
413 if callback_active.get() {
414 cleanup_driver_work(&callback_active, &callback_cancel);
415 out.down(vec![Message::Error(error)]);
416 }
417 }
418 SseDriverEvent::Complete => {
419 if callback_active.get() {
420 cleanup_driver_work(&callback_active, &callback_cancel);
421 out.down(vec![Message::Complete]);
422 }
423 }
424 });
425 let cancel = driver.connect(request.clone(), callback);
426 install_driver_cancel(&active, &cancel_slot, cancel);
427 return;
428 }
429
430 let Some(driver) = ctx.environment().http_stream_driver() else {
431 ctx.down(vec![Message::Error(
432 "fromSSE: missing sse or http stream driver".into(),
433 )]);
434 return;
435 };
436 let parser = Rc::new(RefCell::new(SseParser::default()));
437 let saw_head = Rc::new(Cell::new(false));
438 let callback_active = active.clone();
439 let callback_cancel = cancel_slot.clone();
440 let callback_parser = parser.clone();
441 let callback_saw_head = saw_head.clone();
442 let callback = Rc::new(move |event| {
443 if !callback_active.get() {
444 return;
445 }
446 match event {
447 HttpStreamDriverEvent::Head(head) => {
448 if callback_saw_head.replace(true) {
449 cleanup_driver_work(&callback_active, &callback_cancel);
450 out.down(vec![Message::Error(
451 "fromSSE: http stream emitted duplicate response head".into(),
452 )]);
453 return;
454 }
455 if let Err(error) = validate_sse_head(&head) {
456 cleanup_driver_work(&callback_active, &callback_cancel);
457 out.down(vec![Message::Error(error)]);
458 }
459 }
460 HttpStreamDriverEvent::Chunk(chunk) => {
461 if chunk.is_empty() {
462 return;
463 }
464 if !callback_saw_head.get() {
465 cleanup_driver_work(&callback_active, &callback_cancel);
466 out.down(vec![Message::Error(
467 "fromSSE: http stream chunk arrived before response head".into(),
468 )]);
469 return;
470 }
471 match callback_parser.borrow_mut().push(&chunk) {
472 Ok(events) => {
473 for event in events {
474 if callback_active.get() {
475 out.down(vec![Message::Data(Rc::new(event))]);
476 }
477 }
478 }
479 Err(SseParserError { events, error }) => {
480 for event in events {
481 if callback_active.get() {
482 out.down(vec![Message::Data(Rc::new(event))]);
483 }
484 }
485 if callback_active.get() {
486 cleanup_driver_work(&callback_active, &callback_cancel);
487 out.down(vec![Message::Error(error)]);
488 }
489 }
490 }
491 }
492 HttpStreamDriverEvent::Error(error) => {
493 if callback_active.get() {
494 cleanup_driver_work(&callback_active, &callback_cancel);
495 out.down(vec![Message::Error(error)]);
496 }
497 }
498 HttpStreamDriverEvent::Complete => {
499 if !callback_saw_head.get() {
500 cleanup_driver_work(&callback_active, &callback_cancel);
501 out.down(vec![Message::Error(
502 "fromSSE: http stream completed before response head".into(),
503 )]);
504 return;
505 }
506 match callback_parser.borrow_mut().complete() {
507 Ok(events) => {
508 for event in events {
509 if callback_active.get() {
510 out.down(vec![Message::Data(Rc::new(event))]);
511 }
512 }
513 }
514 Err(SseParserError { events, error }) => {
515 for event in events {
516 if callback_active.get() {
517 out.down(vec![Message::Data(Rc::new(event))]);
518 }
519 }
520 if callback_active.get() {
521 cleanup_driver_work(&callback_active, &callback_cancel);
522 out.down(vec![Message::Error(error)]);
523 }
524 return;
525 }
526 }
527 if callback_active.get() {
528 cleanup_driver_work(&callback_active, &callback_cancel);
529 out.down(vec![Message::Complete]);
530 }
531 }
532 }
533 });
534 let cancel = driver.stream(
535 sse_request_to_http_stream_request(request.clone()),
536 callback,
537 );
538 install_driver_cancel(&active, &cancel_slot, cancel);
539 },
540 )
541}
542
543const SSE_PARSER_MAX_BUFFER_BYTES: usize = 64 * 1024;
544
545fn sse_request_to_http_stream_request(request: SseRequest) -> HttpRequest {
546 let mut headers = request.headers;
547 if !headers
548 .iter()
549 .any(|(key, _)| key.eq_ignore_ascii_case("accept"))
550 {
551 headers.push(("Accept".to_owned(), "text/event-stream".to_owned()));
552 }
553 HttpRequest {
554 method: "GET".to_owned(),
555 url: request.url,
556 headers,
557 body: Vec::new(),
558 }
559}
560
561fn validate_sse_head(head: &HttpStreamHead) -> Result<(), GraphError> {
562 if !(200..=299).contains(&head.status) {
563 return Err(format!("fromSSE: unacceptable http status {}", head.status).into());
564 }
565 let Some(content_type) = head
566 .headers
567 .iter()
568 .find(|(key, _)| key.eq_ignore_ascii_case("content-type"))
569 .map(|(_, value)| value)
570 else {
571 return Err("fromSSE: missing text/event-stream content-type".into());
572 };
573 let media_type = content_type
574 .split(';')
575 .next()
576 .map(str::trim)
577 .unwrap_or_default();
578 if !media_type.eq_ignore_ascii_case("text/event-stream") {
579 return Err(format!("fromSSE: unacceptable content-type {content_type}").into());
580 }
581 Ok(())
582}
583
584#[derive(Default)]
585struct SseParser {
586 line_buffer: Vec<u8>,
587 drop_next_lf: bool,
588 event: Option<String>,
589 data_lines: Vec<String>,
590 data_bytes: usize,
591 id: Option<String>,
592 retry_ms: Option<u64>,
593}
594
595struct SseParserError {
596 events: Vec<SseEvent>,
597 error: GraphError,
598}
599
600impl SseParser {
601 fn push(&mut self, chunk: &[u8]) -> Result<Vec<SseEvent>, SseParserError> {
602 let mut events = Vec::new();
603 for &byte in chunk {
604 if self.drop_next_lf {
605 self.drop_next_lf = false;
606 if byte == b'\n' {
607 continue;
608 }
609 }
610 match byte {
611 b'\r' => {
612 if let Err(error) = self.finish_line_into(&mut events) {
613 return Err(SseParserError { events, error });
614 }
615 self.drop_next_lf = true;
616 }
617 b'\n' => {
618 if let Err(error) = self.finish_line_into(&mut events) {
619 return Err(SseParserError { events, error });
620 }
621 }
622 byte => {
623 self.line_buffer.push(byte);
624 if self.line_buffer.len() > SSE_PARSER_MAX_BUFFER_BYTES {
625 return Err(SseParserError {
626 events,
627 error: "fromSSE: parser overflow".into(),
628 });
629 }
630 }
631 }
632 }
633 Ok(events)
634 }
635
636 fn complete(&mut self) -> Result<Vec<SseEvent>, SseParserError> {
637 let mut events = Vec::new();
638 if !self.line_buffer.is_empty() {
639 if let Err(error) = self.finish_line_into(&mut events) {
640 return Err(SseParserError { events, error });
641 }
642 }
643 if let Some(event) = self.dispatch_event() {
644 events.push(event);
645 }
646 Ok(events)
647 }
648
649 fn finish_line_into(&mut self, events: &mut Vec<SseEvent>) -> Result<(), GraphError> {
650 if let Some(event) = self.finish_line()? {
651 events.push(event);
652 }
653 Ok(())
654 }
655
656 fn finish_line(&mut self) -> Result<Option<SseEvent>, GraphError> {
657 let line = String::from_utf8(std::mem::take(&mut self.line_buffer))
658 .map_err(|_| -> GraphError { "fromSSE: invalid utf-8 in event stream".into() })?;
659 self.process_line(&line)
660 }
661
662 fn process_line(&mut self, line: &str) -> Result<Option<SseEvent>, GraphError> {
663 if line.is_empty() {
664 return Ok(self.dispatch_event());
665 }
666 if line.starts_with(':') {
667 return Ok(None);
668 }
669 let (field, value) = line.split_once(':').unwrap_or((line, ""));
670 let value = value.strip_prefix(' ').unwrap_or(value);
671 match field {
672 "data" => {
673 let added = value.len() + usize::from(!self.data_lines.is_empty());
674 if self.data_bytes + added > SSE_PARSER_MAX_BUFFER_BYTES {
675 return Err("fromSSE: parser overflow".into());
676 }
677 self.data_bytes += added;
678 self.data_lines.push(value.to_owned());
679 }
680 "event" => self.event = Some(value.to_owned()),
681 "id" => self.id = Some(value.to_owned()),
682 "retry" if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) => {
683 if let Ok(retry_ms) = value.parse::<u64>() {
684 self.retry_ms = Some(retry_ms);
685 }
686 }
687 _ => {}
688 }
689 Ok(None)
690 }
691
692 fn dispatch_event(&mut self) -> Option<SseEvent> {
693 if self.data_lines.is_empty() {
694 self.clear_event();
695 return None;
696 }
697 let event = SseEvent {
698 event: self.event.take(),
699 data: self.data_lines.join("\n"),
700 id: self.id.take(),
701 retry_ms: self.retry_ms.take(),
702 };
703 self.clear_event();
704 Some(event)
705 }
706
707 fn clear_event(&mut self) {
708 self.event = None;
709 self.data_lines.clear();
710 self.data_bytes = 0;
711 self.id = None;
712 self.retry_ms = None;
713 }
714}
715
716pub fn from_websocket(url: impl Into<String>) -> Operator<WebSocketEvent> {
718 from_websocket_with_options(WebSocketRequest::new(url))
719}
720
721pub fn from_websocket_with_options(request: WebSocketRequest) -> Operator<WebSocketEvent> {
723 assert!(
724 !request.url.is_empty(),
725 "fromWebSocket: url must be non-empty"
726 );
727 Operator::with_opts(
728 "fromWebSocket",
729 NodeOpts {
730 pool: crate::dispatcher::PoolKind::Async,
731 pausable: Pausable::False,
732 ..NodeOpts::default()
733 },
734 move |ctx| {
735 let Some(driver) = ctx.environment().websocket_driver() else {
736 ctx.down(vec![Message::Error(
737 "fromWebSocket: missing websocket driver".into(),
738 )]);
739 return;
740 };
741 let active = Rc::new(Cell::new(true));
742 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
743 let cleanup_active = active.clone();
744 let cleanup_cancel = cancel_slot.clone();
745 ctx.on_deactivation(move || {
746 cleanup_driver_work(&cleanup_active, &cleanup_cancel);
747 });
748 let out = ctx.defer();
749 let callback_active = active.clone();
750 let callback_cancel = cancel_slot.clone();
751 let callback = Rc::new(move |event| match event {
752 WebSocketDriverEvent::Event(event) => {
753 if callback_active.get() {
754 out.down(vec![Message::Data(Rc::new(event))]);
755 }
756 }
757 WebSocketDriverEvent::Error(error) => {
758 if callback_active.get() {
759 cleanup_driver_work(&callback_active, &callback_cancel);
760 out.down(vec![Message::Error(error)]);
761 }
762 }
763 WebSocketDriverEvent::Complete => {
764 if callback_active.get() {
765 cleanup_driver_work(&callback_active, &callback_cancel);
766 out.down(vec![Message::Complete]);
767 }
768 }
769 });
770 let cancel = driver.connect(request.clone(), callback);
771 install_driver_cancel(&active, &cancel_slot, cancel);
772 },
773 )
774}
775
776pub fn from_webhook(id: impl Into<String>) -> Operator<WebhookEvent> {
782 from_webhook_with_options(WebhookRegistration::new(id))
783}
784
785pub fn from_webhook_with_options(registration: WebhookRegistration) -> Operator<WebhookEvent> {
787 assert!(
788 !registration.id.is_empty(),
789 "fromWebhook: id must be non-empty"
790 );
791 Operator::with_opts(
792 "fromWebhook",
793 NodeOpts {
794 pool: crate::dispatcher::PoolKind::Async,
795 pausable: Pausable::False,
796 ..NodeOpts::default()
797 },
798 move |ctx| {
799 let Some(driver) = ctx.environment().webhook_driver() else {
800 ctx.down(vec![Message::Error(
801 "fromWebhook: missing webhook driver".into(),
802 )]);
803 return;
804 };
805 let active = Rc::new(Cell::new(true));
806 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
807 let cleanup_active = active.clone();
808 let cleanup_cancel = cancel_slot.clone();
809 ctx.on_deactivation(move || {
810 cleanup_driver_work(&cleanup_active, &cleanup_cancel);
811 });
812 let out = ctx.defer();
813 let callback_active = active.clone();
814 let callback_cancel = cancel_slot.clone();
815 let callback = Rc::new(move |event| match event {
816 WebhookDriverEvent::Event(event) => {
817 if callback_active.get() {
818 out.down(vec![Message::Data(Rc::new(event))]);
819 }
820 }
821 WebhookDriverEvent::Error(error) => {
822 if callback_active.get() {
823 cleanup_driver_work(&callback_active, &callback_cancel);
824 out.down(vec![Message::Error(error)]);
825 }
826 }
827 WebhookDriverEvent::Complete => {
828 if callback_active.get() {
829 cleanup_driver_work(&callback_active, &callback_cancel);
830 out.down(vec![Message::Complete]);
831 }
832 }
833 });
834 let cancel = driver.register(registration.clone(), callback);
835 install_driver_cancel(&active, &cancel_slot, cancel);
836 },
837 )
838}
839
840#[derive(Debug, Clone, PartialEq, Eq)]
842pub enum FsEventKind {
843 Change,
845 Rename,
847 Create,
849 Delete,
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct FsEvent {
856 pub kind: FsEventKind,
858 pub path: PathBuf,
860 pub root: PathBuf,
862 pub relative_path: PathBuf,
864}
865
866#[derive(Debug, Clone)]
868pub struct FromFsWatchOptions {
869 pub recursive: bool,
871 pub debounce_ms: u64,
873 pub initial_scan: bool,
875 pub include: Vec<String>,
877 pub exclude: Vec<String>,
879}
880
881#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct CronSchedule {
884 pub minutes: BTreeSet<u8>,
886 pub hours: BTreeSet<u8>,
888 pub days_of_month: BTreeSet<u8>,
890 pub months: BTreeSet<u8>,
892 pub days_of_week: BTreeSet<u8>,
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
898pub struct CronInstant {
899 pub year: i32,
901 pub month: u8,
903 pub day_of_month: u8,
905 pub hour: u8,
907 pub minute: u8,
909 pub day_of_week: u8,
911}
912
913impl CronInstant {
914 #[must_use]
915 pub fn new(
917 year: i32,
918 month: u8,
919 day_of_month: u8,
920 hour: u8,
921 minute: u8,
922 day_of_week: u8,
923 ) -> Self {
924 Self {
925 year,
926 month,
927 day_of_month,
928 hour,
929 minute,
930 day_of_week,
931 }
932 }
933}
934
935#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct CronTick {
938 pub instant: CronInstant,
940 pub timestamp_ns: String,
942}
943
944impl CronTick {
945 #[must_use]
946 pub fn new(instant: CronInstant, timestamp_ns: impl Into<String>) -> Self {
948 Self {
949 instant,
950 timestamp_ns: timestamp_ns.into(),
951 }
952 }
953}
954
955#[derive(Clone)]
956pub struct FromCronOptions {
958 pub tick_ms: u64,
960 pub now: Option<Rc<dyn Fn() -> CronTick>>,
962}
963
964impl Default for FromCronOptions {
965 fn default() -> Self {
966 Self {
967 tick_ms: 60_000,
968 now: None,
969 }
970 }
971}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
975pub struct CronParseError {
976 message: String,
977}
978
979impl CronParseError {
980 fn new(message: impl Into<String>) -> Self {
981 Self {
982 message: message.into(),
983 }
984 }
985}
986
987impl std::fmt::Display for CronParseError {
988 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989 f.write_str(&self.message)
990 }
991}
992
993impl Error for CronParseError {}
994
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub enum GitHookType {
998 PostCommit,
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq)]
1004pub struct GitEvent {
1005 pub hook: GitHookType,
1007 pub commit: String,
1009 pub files: Vec<String>,
1011 pub message: String,
1013 pub author: String,
1015 pub timestamp_ns: String,
1017}
1018
1019#[derive(Debug, Clone)]
1020pub struct FromGitHookOptions {
1022 pub poll_ms: u64,
1024 pub include: Vec<String>,
1026 pub exclude: Vec<String>,
1028 pub max_consecutive_errors: usize,
1030}
1031
1032impl Default for FromGitHookOptions {
1033 fn default() -> Self {
1034 Self {
1035 poll_ms: 5_000,
1036 include: Vec::new(),
1037 exclude: Vec::new(),
1038 max_consecutive_errors: 1,
1039 }
1040 }
1041}
1042
1043#[derive(Debug, Clone)]
1044struct GitPollResult {
1045 head: String,
1046 files: Vec<String>,
1047 message: String,
1048 author: String,
1049}
1050
1051impl Default for FromFsWatchOptions {
1052 fn default() -> Self {
1053 Self {
1054 recursive: false,
1055 debounce_ms: 100,
1056 initial_scan: false,
1057 include: Vec::new(),
1058 exclude: vec![
1059 "**/node_modules/**".to_owned(),
1060 "**/.git/**".to_owned(),
1061 "**/dist/**".to_owned(),
1062 ],
1063 }
1064 }
1065}
1066
1067#[derive(Debug, Clone)]
1068struct WatchRoot {
1069 root: PathBuf,
1070 rel_base: PathBuf,
1071}
1072
1073pub fn from_fs_watch<P>(paths: impl IntoIterator<Item = P>) -> Operator<FsEvent>
1080where
1081 P: Into<PathBuf>,
1082{
1083 from_fs_watch_with_options(paths, FromFsWatchOptions::default())
1084}
1085
1086pub fn from_fs_watch_with_options<P>(
1088 paths: impl IntoIterator<Item = P>,
1089 opts: FromFsWatchOptions,
1090) -> Operator<FsEvent>
1091where
1092 P: Into<PathBuf>,
1093{
1094 let roots: Vec<WatchRoot> = paths
1095 .into_iter()
1096 .map(|path| watch_root(absolutize(path.into())))
1097 .collect();
1098 assert!(!roots.is_empty(), "from_fs_watch: paths must not be empty");
1099 Operator::with_opts(
1100 "fromFSWatch",
1101 NodeOpts {
1102 pausable: Pausable::False,
1103 ..NodeOpts::default()
1104 },
1105 move |ctx| {
1106 let Some(driver) = ctx.local_async_driver() else {
1107 ctx.down(vec![Message::Error(
1108 "fromFSWatch: missing local async driver".into(),
1109 )]);
1110 return;
1111 };
1112
1113 let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
1114 let mut watcher = match notify::recommended_watcher(tx) {
1115 Ok(watcher) => watcher,
1116 Err(error) => {
1117 ctx.down(vec![Message::Error(error.into())]);
1118 return;
1119 }
1120 };
1121 let mode = if opts.recursive {
1122 RecursiveMode::Recursive
1123 } else {
1124 RecursiveMode::NonRecursive
1125 };
1126 for root in &roots {
1127 if let Err(error) = watcher.watch(&root.root, mode) {
1128 ctx.down(vec![Message::Error(error.into())]);
1129 return;
1130 }
1131 }
1132
1133 let out = ctx.defer();
1134 let active = Rc::new(Cell::new(true));
1135 let active_tick = active.clone();
1136 let roots_tick = roots.clone();
1137 let opts_tick = opts.clone();
1138 let rx = Rc::new(RefCell::new(rx));
1139 let watcher_slot = Rc::new(RefCell::new(Some(watcher)));
1140 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
1141 let watcher_tick = watcher_slot.clone();
1142 let cancel_tick = cancel_slot.clone();
1143 let period = Duration::from_millis(opts.debounce_ms.max(1));
1144 let cancel_poll = driver.interval(
1145 period,
1146 Rc::new(move || {
1147 if !active_tick.get() {
1148 return;
1149 }
1150 let mut messages: Vec<Message<AnyValue>> = Vec::new();
1151 loop {
1152 match rx.borrow_mut().try_recv() {
1153 Ok(Ok(event)) => {
1154 for fs_event in event_to_fs_events(event, &roots_tick, &opts_tick) {
1155 let out: AnyValue = Rc::new(fs_event);
1156 messages.push(Message::Data(out));
1157 }
1158 }
1159 Ok(Err(error)) => {
1160 cleanup_fs_watch(&active_tick, &cancel_tick, &watcher_tick);
1161 out.down(vec![Message::Error(error.into())]);
1162 return;
1163 }
1164 Err(mpsc::TryRecvError::Empty) => break,
1165 Err(mpsc::TryRecvError::Disconnected) => {
1166 active_tick.set(false);
1167 break;
1168 }
1169 }
1170 }
1171 if !messages.is_empty() {
1172 out.down(messages);
1173 }
1174 }),
1175 );
1176 *cancel_slot.borrow_mut() = Some(cancel_poll);
1177 let cleanup_active = active.clone();
1178 let cleanup_cancel = cancel_slot.clone();
1179 let cleanup_watcher = watcher_slot.clone();
1180 ctx.on_deactivation(move || {
1181 cleanup_fs_watch(&cleanup_active, &cleanup_cancel, &cleanup_watcher);
1182 });
1183
1184 if opts.initial_scan {
1185 let initial = initial_scan_events(&roots, &opts);
1186 if !initial.is_empty() {
1187 ctx.down(
1188 initial
1189 .into_iter()
1190 .map(|event| {
1191 let out: AnyValue = Rc::new(event);
1192 Message::Data(out)
1193 })
1194 .collect(),
1195 );
1196 }
1197 }
1198 },
1199 )
1200}
1201
1202fn initial_scan_events(roots: &[WatchRoot], opts: &FromFsWatchOptions) -> Vec<FsEvent> {
1203 let mut out = Vec::new();
1204 for root in roots {
1205 scan_root(root, opts.recursive, opts, &mut |path| {
1206 if let Some(event) = fs_event_for(FsEventKind::Create, path, root, opts) {
1207 out.push(event);
1208 }
1209 });
1210 }
1211 out
1212}
1213
1214fn scan_root(
1215 root: &WatchRoot,
1216 recursive: bool,
1217 opts: &FromFsWatchOptions,
1218 visit: &mut impl FnMut(&Path),
1219) {
1220 let Ok(meta) = fs::symlink_metadata(&root.root) else {
1221 return;
1222 };
1223 if meta.file_type().is_symlink() {
1224 return;
1225 }
1226 if meta.is_file() {
1227 visit(&root.root);
1228 return;
1229 }
1230 if !meta.is_dir() {
1231 return;
1232 }
1233 let Ok(entries) = fs::read_dir(&root.root) else {
1234 return;
1235 };
1236 for entry in entries.flatten() {
1237 let path = entry.path();
1238 let Ok(meta) = fs::symlink_metadata(&path) else {
1239 continue;
1240 };
1241 if meta.file_type().is_symlink() {
1242 continue;
1243 }
1244 if meta.is_file() {
1245 visit(&path);
1246 } else if recursive && meta.is_dir() {
1247 let relative = path
1248 .strip_prefix(&root.rel_base)
1249 .map_or_else(|_| path.clone(), Path::to_path_buf);
1250 if is_excluded_path(&path, &relative, opts) {
1251 continue;
1252 }
1253 scan_root(
1254 &watch_root_with_base(path, root.rel_base.clone()),
1255 recursive,
1256 opts,
1257 visit,
1258 );
1259 }
1260 }
1261}
1262
1263fn event_to_fs_events(
1264 event: notify::Event,
1265 roots: &[WatchRoot],
1266 opts: &FromFsWatchOptions,
1267) -> Vec<FsEvent> {
1268 let kind = match event.kind {
1269 EventKind::Create(_) => Some(FsEventKind::Create),
1270 EventKind::Remove(_) => Some(FsEventKind::Delete),
1271 EventKind::Modify(ModifyKind::Name(_)) => Some(FsEventKind::Rename),
1272 EventKind::Modify(_) | EventKind::Any | EventKind::Other => Some(FsEventKind::Change),
1273 EventKind::Access(_) => None,
1274 };
1275 let Some(kind) = kind else {
1276 return Vec::new();
1277 };
1278 event
1279 .paths
1280 .iter()
1281 .filter_map(|path| {
1282 let abs = absolutize(path.clone());
1283 let root = roots
1284 .iter()
1285 .filter(|root| abs.starts_with(root.root.as_path()))
1286 .max_by_key(|root| root.root.as_os_str().len())?;
1287 fs_event_for(kind.clone(), &abs, root, opts)
1288 })
1289 .collect()
1290}
1291
1292fn fs_event_for(
1293 kind: FsEventKind,
1294 path: &Path,
1295 root: &WatchRoot,
1296 opts: &FromFsWatchOptions,
1297) -> Option<FsEvent> {
1298 let relative = path
1299 .strip_prefix(&root.rel_base)
1300 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf);
1301 if !accepts_path(path, &relative, opts) {
1302 return None;
1303 }
1304 Some(FsEvent {
1305 kind,
1306 path: path.to_path_buf(),
1307 root: root.root.clone(),
1308 relative_path: relative,
1309 })
1310}
1311
1312fn accepts_path(path: &Path, relative: &Path, opts: &FromFsWatchOptions) -> bool {
1313 if is_excluded_path(path, relative, opts) {
1314 return false;
1315 }
1316 let abs = normalize_path(path);
1317 let rel = normalize_path(relative);
1318 opts.include.is_empty()
1319 || opts
1320 .include
1321 .iter()
1322 .any(|pattern| wildcard_match(pattern, &abs) || wildcard_match(pattern, &rel))
1323}
1324
1325fn is_excluded_path(path: &Path, relative: &Path, opts: &FromFsWatchOptions) -> bool {
1326 let abs = normalize_path(path);
1327 let rel = normalize_path(relative);
1328 opts.exclude.iter().any(|pattern| {
1329 wildcard_match(pattern, &abs)
1330 || wildcard_match(pattern, &rel)
1331 || wildcard_match(pattern, &format!("{abs}/"))
1332 || (!rel.is_empty() && wildcard_match(pattern, &format!("{rel}/")))
1333 })
1334}
1335
1336fn watch_root(root: PathBuf) -> WatchRoot {
1337 let rel_base = if root.is_file() {
1338 root.parent().unwrap_or(root.as_path()).to_path_buf()
1339 } else {
1340 root.clone()
1341 };
1342 WatchRoot { root, rel_base }
1343}
1344
1345fn watch_root_with_base(root: PathBuf, rel_base: PathBuf) -> WatchRoot {
1346 WatchRoot { root, rel_base }
1347}
1348
1349fn cleanup_fs_watch(
1350 active: &Rc<Cell<bool>>,
1351 cancel_slot: &Rc<RefCell<Option<DriverCancel>>>,
1352 watcher_slot: &Rc<RefCell<Option<RecommendedWatcher>>>,
1353) {
1354 active.set(false);
1355 if let Some(cancel) = cancel_slot.borrow_mut().take() {
1356 cancel();
1357 }
1358 watcher_slot.borrow_mut().take();
1359}
1360
1361fn cleanup_driver_interval(
1362 active: &Rc<Cell<bool>>,
1363 cancel_slot: &Rc<RefCell<Option<DriverCancel>>>,
1364) {
1365 active.set(false);
1366 if let Some(cancel) = cancel_slot.borrow_mut().take() {
1367 cancel();
1368 }
1369}
1370
1371fn cleanup_driver_work(active: &Rc<Cell<bool>>, cancel_slot: &Rc<RefCell<Option<DriverCancel>>>) {
1372 active.set(false);
1373 if let Some(cancel) = cancel_slot.borrow_mut().take() {
1374 cancel();
1375 }
1376}
1377
1378fn install_driver_cancel(
1379 active: &Rc<Cell<bool>>,
1380 cancel_slot: &Rc<RefCell<Option<DriverCancel>>>,
1381 cancel: DriverCancel,
1382) {
1383 if active.get() {
1384 *cancel_slot.borrow_mut() = Some(cancel);
1385 } else {
1386 cancel();
1387 }
1388}
1389
1390fn absolutize(path: PathBuf) -> PathBuf {
1391 if path.is_absolute() {
1392 path
1393 } else {
1394 std::env::current_dir()
1395 .unwrap_or_else(|_| PathBuf::from("."))
1396 .join(path)
1397 }
1398}
1399
1400fn normalize_path(path: &Path) -> String {
1401 path.to_string_lossy().replace('\\', "/")
1402}
1403
1404fn wildcard_match(pattern: &str, value: &str) -> bool {
1405 let (mut p, mut v) = (0usize, 0usize);
1406 let (mut star, mut star_v) = (None, 0usize);
1407 let p_bytes = pattern.as_bytes();
1408 let v_bytes = value.as_bytes();
1409 while v < v_bytes.len() {
1410 if p < p_bytes.len() && p_bytes[p] == b'*' {
1411 star = Some(p);
1412 p += 1;
1413 star_v = v;
1414 } else if p < p_bytes.len() && p_bytes[p] == v_bytes[v] {
1415 p += 1;
1416 v += 1;
1417 } else if let Some(star_p) = star {
1418 p = star_p + 1;
1419 star_v += 1;
1420 v = star_v;
1421 } else {
1422 return false;
1423 }
1424 }
1425 while p < p_bytes.len() && p_bytes[p] == b'*' {
1426 p += 1;
1427 }
1428 p == p_bytes.len()
1429}
1430
1431fn parse_cron_field(field: &str, min: u8, max: u8) -> Result<BTreeSet<u8>, CronParseError> {
1432 if field.is_empty() {
1433 return Err(CronParseError::new("Invalid cron field: empty"));
1434 }
1435 let mut out = BTreeSet::new();
1436 for part in field.split(',') {
1437 if part.is_empty() {
1438 return Err(CronParseError::new(format!("Invalid cron field: {field}")));
1439 }
1440 let step_parts: Vec<_> = part.split('/').collect();
1441 if step_parts.len() > 2 || step_parts[0].is_empty() {
1442 return Err(CronParseError::new(format!("Invalid cron step: {part}")));
1443 }
1444 let step = if step_parts.len() == 2 {
1445 if step_parts[1].is_empty() {
1446 return Err(CronParseError::new(format!("Invalid cron step: {part}")));
1447 }
1448 parse_cron_int(step_parts[1], format!("Invalid cron step: {part}"))?
1449 } else {
1450 1
1451 };
1452 if step < 1 {
1453 return Err(CronParseError::new(format!("Invalid cron step: {part}")));
1454 }
1455
1456 let range = step_parts[0];
1457 let (start, end) = if range == "*" {
1458 (min, max)
1459 } else if range.contains('-') {
1460 let pieces: Vec<_> = range.split('-').collect();
1461 if pieces.len() != 2 || pieces[0].is_empty() || pieces[1].is_empty() {
1462 return Err(CronParseError::new(format!("Invalid cron field: {field}")));
1463 }
1464 (
1465 parse_cron_int(pieces[0], format!("Invalid cron field: {field}"))?,
1466 parse_cron_int(pieces[1], format!("Invalid cron field: {field}"))?,
1467 )
1468 } else {
1469 let value = parse_cron_int(range, format!("Invalid cron field: {field}"))?;
1470 (value, value)
1471 };
1472
1473 if start < min || end > max {
1474 return Err(CronParseError::new(format!(
1475 "Cron field out of range: {field} ({min}-{max})"
1476 )));
1477 }
1478 if start > end {
1479 return Err(CronParseError::new(format!(
1480 "Invalid cron range: {start}-{end} in {field}"
1481 )));
1482 }
1483 let mut value = start;
1484 while value <= end {
1485 out.insert(value);
1486 match value.checked_add(step) {
1487 Some(next) => value = next,
1488 None => break,
1489 }
1490 }
1491 }
1492 Ok(out)
1493}
1494
1495fn parse_cron_int(text: &str, message: String) -> Result<u8, CronParseError> {
1496 if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
1497 return Err(CronParseError::new(message));
1498 }
1499 text.parse::<u8>().map_err(|_| CronParseError::new(message))
1500}
1501
1502pub fn parse_cron(expr: &str) -> Result<CronSchedule, CronParseError> {
1504 let parts: Vec<_> = expr.split_whitespace().collect();
1505 if parts.len() != 5 {
1506 return Err(CronParseError::new(format!(
1507 "Invalid cron: expected 5 fields, got {}",
1508 parts.len()
1509 )));
1510 }
1511 Ok(CronSchedule {
1512 minutes: parse_cron_field(parts[0], 0, 59)?,
1513 hours: parse_cron_field(parts[1], 0, 23)?,
1514 days_of_month: parse_cron_field(parts[2], 1, 31)?,
1515 months: parse_cron_field(parts[3], 1, 12)?,
1516 days_of_week: parse_cron_field(parts[4], 0, 6)?,
1517 })
1518}
1519
1520#[must_use]
1522pub fn matches_cron(schedule: &CronSchedule, instant: CronInstant) -> bool {
1523 let day_of_month_matches = schedule.days_of_month.contains(&instant.day_of_month);
1524 let day_of_week_matches = schedule.days_of_week.contains(&instant.day_of_week);
1525 let day_of_month_restricted = !is_full_range(&schedule.days_of_month, 1, 31);
1526 let day_of_week_restricted = !is_full_range(&schedule.days_of_week, 0, 6);
1527 let day_matches = if day_of_month_restricted && day_of_week_restricted {
1528 day_of_month_matches || day_of_week_matches
1529 } else {
1530 day_of_month_matches && day_of_week_matches
1531 };
1532
1533 schedule.minutes.contains(&instant.minute)
1534 && schedule.hours.contains(&instant.hour)
1535 && schedule.months.contains(&instant.month)
1536 && day_matches
1537}
1538
1539fn is_full_range(values: &BTreeSet<u8>, min: u8, max: u8) -> bool {
1540 values.len() == usize::from(max - min + 1)
1541 && values.first().copied() == Some(min)
1542 && values.last().copied() == Some(max)
1543}
1544
1545fn cron_minute_key(instant: CronInstant) -> (i32, u8, u8, u8, u8) {
1546 (
1547 instant.year,
1548 instant.month,
1549 instant.day_of_month,
1550 instant.hour,
1551 instant.minute,
1552 )
1553}
1554
1555fn default_cron_tick_utc() -> CronTick {
1556 let duration = SystemTime::now()
1557 .duration_since(UNIX_EPOCH)
1558 .unwrap_or(Duration::ZERO);
1559 let secs = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
1560 let nanos = duration.as_nanos();
1561 let days = secs.div_euclid(86_400);
1562 let seconds_of_day = secs.rem_euclid(86_400);
1563 let (year, month, day_of_month) = civil_from_days(days);
1564 let hour = u8::try_from(seconds_of_day / 3_600).unwrap_or(23);
1565 let minute = u8::try_from((seconds_of_day % 3_600) / 60).unwrap_or(59);
1566 let day_of_week = u8::try_from((days + 4).rem_euclid(7)).unwrap_or(0);
1567 CronTick::new(
1568 CronInstant::new(year, month, day_of_month, hour, minute, day_of_week),
1569 nanos.to_string(),
1570 )
1571}
1572
1573fn civil_from_days(days_since_unix_epoch: i64) -> (i32, u8, u8) {
1574 let z = days_since_unix_epoch + 719_468;
1575 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1576 let doe = z - era * 146_097;
1577 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1578 let year = yoe + era * 400;
1579 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1580 let mp = (5 * doy + 2) / 153;
1581 let day = doy - (153 * mp + 2) / 5 + 1;
1582 let month = mp + if mp < 10 { 3 } else { -9 };
1583 let year = year + i64::from(month <= 2);
1584 (
1585 i32::try_from(year).unwrap_or(i32::MAX),
1586 u8::try_from(month).unwrap_or(12),
1587 u8::try_from(day).unwrap_or(31),
1588 )
1589}
1590
1591pub fn from_cron(expr: &str) -> Operator<CronTick> {
1598 from_cron_with_options(expr, FromCronOptions::default())
1599}
1600
1601pub fn from_cron_with_options(expr: &str, opts: FromCronOptions) -> Operator<CronTick> {
1603 assert!(opts.tick_ms > 0, "from_cron: tick_ms must be positive");
1604 let schedule = parse_cron(expr).expect("from_cron: invalid cron expression");
1605 let tick_ms = opts.tick_ms;
1606 let now = opts
1607 .now
1608 .unwrap_or_else(|| Rc::new(default_cron_tick_utc) as Rc<dyn Fn() -> CronTick>);
1609 Operator::with_opts(
1610 "fromCron",
1611 NodeOpts {
1612 pausable: Pausable::False,
1613 ..NodeOpts::default()
1614 },
1615 move |ctx| {
1616 let Some(driver) = ctx.local_async_driver() else {
1617 ctx.down(vec![Message::Error(
1618 "fromCron: missing local async driver".into(),
1619 )]);
1620 return;
1621 };
1622 let schedule = schedule.clone();
1623 let now = now.clone();
1624 let last_fired = Rc::new(RefCell::new(None::<(i32, u8, u8, u8, u8)>));
1625 let active = Rc::new(Cell::new(true));
1626 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
1627 let out = ctx.defer();
1628 let last_fired_interval = last_fired.clone();
1629 let schedule_interval = schedule.clone();
1630 let now_interval = now.clone();
1631 let active_interval = active.clone();
1632 let cancel = driver.interval(
1633 Duration::from_millis(tick_ms),
1634 Rc::new(move || {
1635 if !active_interval.get() {
1636 return;
1637 }
1638 let tick = now_interval();
1639 let key = cron_minute_key(tick.instant);
1640 if matches_cron(&schedule_interval, tick.instant)
1641 && *last_fired_interval.borrow() != Some(key)
1642 {
1643 *last_fired_interval.borrow_mut() = Some(key);
1644 out.down(vec![Message::Data(Rc::new(tick))]);
1645 }
1646 }),
1647 );
1648 *cancel_slot.borrow_mut() = Some(cancel);
1649 let cleanup_active = active.clone();
1650 let cleanup_cancel = cancel_slot.clone();
1651 ctx.on_deactivation(move || {
1652 cleanup_driver_interval(&cleanup_active, &cleanup_cancel);
1653 });
1654
1655 let tick = now();
1656 let key = cron_minute_key(tick.instant);
1657 if active.get()
1658 && matches_cron(&schedule, tick.instant)
1659 && *last_fired.borrow() != Some(key)
1660 {
1661 *last_fired.borrow_mut() = Some(key);
1662 ctx.down(vec![Message::Data(Rc::new(tick))]);
1663 }
1664 },
1665 )
1666}
1667
1668fn git_text(repo_path: &Path, args: &[&str]) -> Result<String, String> {
1669 let output = Command::new("git")
1670 .arg("-C")
1671 .arg(repo_path)
1672 .args(args)
1673 .output()
1674 .map_err(|error| format!("git: {error}"))?;
1675 if !output.status.success() {
1676 let stderr = String::from_utf8_lossy(&output.stderr);
1677 return Err(format!("git {:?}: {}", args, stderr.trim()));
1678 }
1679 Ok(strip_final_line_break(
1680 String::from_utf8_lossy(&output.stdout).into_owned(),
1681 ))
1682}
1683
1684fn git_path_list(repo_path: &Path, args: &[&str]) -> Result<Vec<String>, String> {
1685 let output = Command::new("git")
1686 .arg("-C")
1687 .arg(repo_path)
1688 .args(args)
1689 .arg("-z")
1690 .output()
1691 .map_err(|error| format!("git: {error}"))?;
1692 if !output.status.success() {
1693 let stderr = String::from_utf8_lossy(&output.stderr);
1694 return Err(format!("git {:?}: {}", args, stderr.trim()));
1695 }
1696 let text = String::from_utf8_lossy(&output.stdout);
1697 Ok(text
1698 .split('\0')
1699 .filter(|part| !part.is_empty())
1700 .map(ToOwned::to_owned)
1701 .collect())
1702}
1703
1704fn strip_final_line_break(value: String) -> String {
1705 value
1706 .strip_suffix("\r\n")
1707 .or_else(|| value.strip_suffix('\n'))
1708 .unwrap_or(&value)
1709 .to_owned()
1710}
1711
1712fn read_git_head(
1713 repo_path: &Path,
1714 previous_head: Option<&str>,
1715) -> Result<Option<GitPollResult>, String> {
1716 let head = git_text(repo_path, &["rev-parse", "HEAD"])?;
1717 if head.is_empty() || Some(head.as_str()) == previous_head {
1718 return Ok(None);
1719 }
1720 let files = if let Some(previous) = previous_head {
1721 git_path_list(
1722 repo_path,
1723 &["diff", "--name-only", &format!("{previous}..{head}")],
1724 )?
1725 } else {
1726 Vec::new()
1727 };
1728 let message = git_text(repo_path, &["log", "-1", "--format=%s", &head])?;
1729 let author = git_text(repo_path, &["log", "-1", "--format=%an", &head])?;
1730 Ok(Some(GitPollResult {
1731 head,
1732 files,
1733 message,
1734 author,
1735 }))
1736}
1737
1738fn timestamp_ns_now() -> String {
1739 SystemTime::now()
1740 .duration_since(UNIX_EPOCH)
1741 .unwrap_or(Duration::ZERO)
1742 .as_nanos()
1743 .to_string()
1744}
1745
1746fn accepts_git_file(file: &str, opts: &FromGitHookOptions) -> bool {
1747 let normalized = file.replace('\\', "/");
1748 let included = opts.include.is_empty()
1749 || opts
1750 .include
1751 .iter()
1752 .any(|pattern| wildcard_match(pattern, &normalized));
1753 included
1754 && !opts
1755 .exclude
1756 .iter()
1757 .any(|pattern| wildcard_match(pattern, &normalized))
1758}
1759
1760fn poll_git_hook(
1761 repo_path: &Path,
1762 opts: &FromGitHookOptions,
1763 last_seen: &Rc<RefCell<Option<String>>>,
1764) -> Result<Option<GitEvent>, String> {
1765 let previous = last_seen.borrow().clone();
1766 let Some(result) = read_git_head(repo_path, previous.as_deref())? else {
1767 return Ok(None);
1768 };
1769 let is_baseline = previous.is_none();
1770 *last_seen.borrow_mut() = Some(result.head.clone());
1771 if is_baseline {
1772 return Ok(None);
1773 }
1774 Ok(Some(GitEvent {
1775 hook: GitHookType::PostCommit,
1776 commit: result.head,
1777 files: result
1778 .files
1779 .into_iter()
1780 .filter(|file| accepts_git_file(file, opts))
1781 .collect(),
1782 message: result.message,
1783 author: result.author,
1784 timestamp_ns: timestamp_ns_now(),
1785 }))
1786}
1787
1788pub fn from_git_hook<P>(repo_path: P) -> Operator<GitEvent>
1794where
1795 P: Into<PathBuf>,
1796{
1797 from_git_hook_with_options(repo_path, FromGitHookOptions::default())
1798}
1799
1800pub fn from_git_hook_with_options<P>(repo_path: P, opts: FromGitHookOptions) -> Operator<GitEvent>
1802where
1803 P: Into<PathBuf>,
1804{
1805 assert!(opts.poll_ms > 0, "from_git_hook: poll_ms must be positive");
1806 assert!(
1807 opts.max_consecutive_errors > 0,
1808 "from_git_hook: max_consecutive_errors must be positive"
1809 );
1810 let repo_path = absolutize(repo_path.into());
1811 Operator::with_opts(
1812 "fromGitHook",
1813 NodeOpts {
1814 pausable: Pausable::False,
1815 ..NodeOpts::default()
1816 },
1817 move |ctx| {
1818 let Some(driver) = ctx.local_async_driver() else {
1819 ctx.down(vec![Message::Error(
1820 "fromGitHook: missing local async driver".into(),
1821 )]);
1822 return;
1823 };
1824 let repo_path = repo_path.clone();
1825 let opts = opts.clone();
1826 let last_seen = Rc::new(RefCell::new(None::<String>));
1827 let consecutive_errors = Rc::new(Cell::new(0usize));
1828 let active = Rc::new(Cell::new(true));
1829 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> = Rc::new(RefCell::new(None));
1830
1831 let out = ctx.defer();
1832 let repo_interval = repo_path.clone();
1833 let opts_interval = opts.clone();
1834 let last_seen_interval = last_seen.clone();
1835 let consecutive_errors_interval = consecutive_errors.clone();
1836 let active_interval = active.clone();
1837 let cancel_interval = cancel_slot.clone();
1838 let cancel = driver.interval(
1839 Duration::from_millis(opts.poll_ms),
1840 Rc::new(move || {
1841 if !active_interval.get() {
1842 return;
1843 }
1844 match poll_git_hook(&repo_interval, &opts_interval, &last_seen_interval) {
1845 Ok(Some(event)) => {
1846 consecutive_errors_interval.set(0);
1847 out.down(vec![Message::Data(Rc::new(event))]);
1848 }
1849 Ok(None) => {
1850 consecutive_errors_interval.set(0);
1851 }
1852 Err(error) => {
1853 let next = consecutive_errors_interval.get() + 1;
1854 consecutive_errors_interval.set(next);
1855 if next >= opts_interval.max_consecutive_errors {
1856 cleanup_driver_interval(&active_interval, &cancel_interval);
1857 out.down(vec![Message::Error(error.into())]);
1858 }
1859 }
1860 }
1861 }),
1862 );
1863 *cancel_slot.borrow_mut() = Some(cancel);
1864 let active_cleanup = active.clone();
1865 let cancel_cleanup = cancel_slot.clone();
1866 ctx.on_deactivation(move || {
1867 cleanup_driver_interval(&active_cleanup, &cancel_cleanup);
1868 });
1869
1870 match poll_git_hook(&repo_path, &opts, &last_seen) {
1871 Ok(Some(event)) => {
1872 consecutive_errors.set(0);
1873 ctx.down(vec![Message::Data(Rc::new(event))]);
1874 }
1875 Ok(None) => {
1876 consecutive_errors.set(0);
1877 }
1878 Err(error) => {
1879 let next = consecutive_errors.get() + 1;
1880 consecutive_errors.set(next);
1881 if next >= opts.max_consecutive_errors {
1882 cleanup_driver_interval(&active, &cancel_slot);
1883 ctx.down(vec![Message::Error(error.into())]);
1884 }
1885 }
1886 }
1887 },
1888 )
1889}
1890
1891fn timer_source(factory: &'static str, ms: u64) -> Operator<u64> {
1892 let duration = Duration::from_millis(ms);
1893 Operator::with_opts(
1894 factory,
1895 NodeOpts {
1896 pausable: Pausable::False,
1897 ..NodeOpts::default()
1898 },
1899 move |ctx| {
1900 let Some(driver) = ctx.local_async_driver() else {
1901 ctx.down(vec![Message::Error(
1902 format!("{factory}: missing local async driver").into(),
1903 )]);
1904 return;
1905 };
1906 let out = ctx.defer();
1907 let cancel = driver.sleep(
1908 duration,
1909 Box::new(move || {
1910 out.down(vec![Message::Data(Rc::new(0u64)), Message::Complete]);
1911 }),
1912 );
1913 ctx.on_deactivation(cancel);
1914 },
1915 )
1916}
1917
1918pub fn timer(ms: u64) -> Operator<u64> {
1922 timer_source("timer", ms)
1923}
1924
1925pub fn from_timer(ms: u64) -> Operator<u64> {
1929 timer_source("fromTimer", ms)
1930}
1931
1932pub fn interval(ms: u64) -> Operator<u64> {
1936 let period = Duration::from_millis(ms);
1937 Operator::with_opts(
1938 "interval",
1939 NodeOpts {
1940 pausable: Pausable::False,
1941 ..NodeOpts::default()
1942 },
1943 move |ctx| {
1944 let Some(driver) = ctx.local_async_driver() else {
1945 ctx.down(vec![Message::Error(
1946 "interval: missing local async driver".into(),
1947 )]);
1948 return;
1949 };
1950 let out = ctx.defer();
1951 let count = Rc::new(Cell::new(0u64));
1952 let tick = Rc::new(move || {
1953 let next = count.get();
1954 count.set(next + 1);
1955 out.down(vec![Message::Data(Rc::new(next))]);
1956 });
1957 let cancel = driver.interval(period, tick);
1958 ctx.on_deactivation(cancel);
1959 },
1960 )
1961}
1962
1963pub fn future_local<T, E, Fut>(make: impl Fn() -> Fut + 'static) -> Operator<T>
1969where
1970 T: 'static,
1971 E: Error + 'static,
1972 Fut: Future<Output = Result<T, E>> + 'static,
1973{
1974 Operator::with_opts(
1975 "futureLocal",
1976 NodeOpts {
1977 pool: crate::dispatcher::PoolKind::Async,
1978 ..NodeOpts::default()
1979 },
1980 move |ctx| {
1981 let Some(driver) = ctx.local_async_driver() else {
1982 ctx.down(vec![Message::Error(
1983 "futureLocal: missing local async driver".into(),
1984 )]);
1985 return;
1986 };
1987 let future = make();
1988 let out = ctx.defer();
1989 let cancel = driver.spawn_local(Box::pin(async move {
1990 match future.await {
1991 Ok(value) => out.down(vec![Message::Data(Rc::new(value)), Message::Complete]),
1992 Err(error) => out.down(vec![Message::Error(error.into())]),
1993 }
1994 }));
1995 ctx.on_deactivation(cancel);
1996 },
1997 )
1998}
1999
2000pub fn stream_local<T, E, S>(make: impl Fn() -> S + 'static) -> Operator<T>
2004where
2005 T: 'static,
2006 E: Error + 'static,
2007 S: Stream<Item = Result<T, E>> + 'static,
2008{
2009 Operator::with_opts(
2010 "streamLocal",
2011 NodeOpts {
2012 pool: crate::dispatcher::PoolKind::Async,
2013 ..NodeOpts::default()
2014 },
2015 move |ctx| {
2016 let Some(driver) = ctx.local_async_driver() else {
2017 ctx.down(vec![Message::Error(
2018 "streamLocal: missing local async driver".into(),
2019 )]);
2020 return;
2021 };
2022 let mut stream = Box::pin(make()) as Pin<Box<dyn Stream<Item = Result<T, E>>>>;
2023 let out = ctx.defer();
2024 let cancel = driver.spawn_local(Box::pin(async move {
2025 loop {
2026 let next = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
2027 match next {
2028 Some(Ok(value)) => out.down(vec![Message::Data(Rc::new(value))]),
2029 Some(Err(error)) => {
2030 out.down(vec![Message::Error(error.into())]);
2031 break;
2032 }
2033 None => {
2034 out.down(vec![Message::Complete]);
2035 break;
2036 }
2037 }
2038 }
2039 }));
2040 ctx.on_deactivation(cancel);
2041 },
2042 )
2043}
2044
2045#[cfg(test)]
2046mod tests {
2047 use super::*;
2048 use notify::event::AccessKind;
2049 use std::time::{SystemTime, UNIX_EPOCH};
2050
2051 fn temp_dir(label: &str) -> PathBuf {
2052 let nanos = SystemTime::now()
2053 .duration_since(UNIX_EPOCH)
2054 .expect("system time is after epoch")
2055 .as_nanos();
2056 let dir = std::env::temp_dir().join(format!(
2057 "graphrefly-rs-source-{label}-{}-{nanos}",
2058 std::process::id()
2059 ));
2060 fs::create_dir_all(&dir).expect("temp dir can be created");
2061 dir
2062 }
2063
2064 #[test]
2065 fn fs_event_conversion_drops_access_and_out_of_root_paths() {
2066 let root = temp_dir("convert-root");
2067 let outside = temp_dir("convert-outside");
2068 let opts = FromFsWatchOptions::default();
2069 let roots = vec![watch_root(root.clone())];
2070
2071 let access =
2072 notify::Event::new(EventKind::Access(AccessKind::Any)).add_path(root.join("a.txt"));
2073 assert!(event_to_fs_events(access, &roots, &opts).is_empty());
2074
2075 let outside_event =
2076 notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(outside.join("b.txt"));
2077 assert!(event_to_fs_events(outside_event, &roots, &opts).is_empty());
2078
2079 fs::remove_dir_all(root).ok();
2080 fs::remove_dir_all(outside).ok();
2081 }
2082
2083 #[test]
2084 fn fs_event_conversion_uses_longest_root_and_file_relative_base() {
2085 let root = temp_dir("convert-longest");
2086 let nested = root.join("nested");
2087 fs::create_dir_all(&nested).expect("nested dir can be created");
2088 let file = nested.join("a.txt");
2089 fs::write(&file, "a").expect("file can be written");
2090 let opts = FromFsWatchOptions::default();
2091
2092 let roots = vec![watch_root(root.clone()), watch_root(nested.clone())];
2093 let nested_event =
2094 notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(file.clone());
2095 let converted = event_to_fs_events(nested_event, &roots, &opts);
2096 assert_eq!(converted.len(), 1);
2097 assert_eq!(converted[0].root, nested);
2098 assert_eq!(converted[0].relative_path, PathBuf::from("a.txt"));
2099
2100 let roots = vec![watch_root(file.clone())];
2101 let file_event = notify::Event::new(EventKind::Modify(ModifyKind::Any)).add_path(file);
2102 let converted = event_to_fs_events(file_event, &roots, &opts);
2103 assert_eq!(converted.len(), 1);
2104 assert_eq!(converted[0].relative_path, PathBuf::from("a.txt"));
2105
2106 fs::remove_dir_all(root).ok();
2107 }
2108
2109 #[test]
2110 fn fs_initial_scan_skips_excluded_dirs() {
2111 let root = temp_dir("scan-exclude");
2112 fs::create_dir_all(root.join("dist")).expect("dist dir can be created");
2113 fs::write(root.join("dist").join("ignored.txt"), "ignored").expect("ignored file");
2114 fs::write(root.join("kept.txt"), "kept").expect("kept file");
2115 let opts = FromFsWatchOptions {
2116 recursive: true,
2117 initial_scan: true,
2118 include: vec!["*.txt".to_owned()],
2119 ..FromFsWatchOptions::default()
2120 };
2121 let events = initial_scan_events(&[watch_root(root.clone())], &opts);
2122
2123 let rels: Vec<_> = events
2124 .into_iter()
2125 .map(|event| event.relative_path)
2126 .collect();
2127 assert_eq!(rels, vec![PathBuf::from("kept.txt")]);
2128 fs::remove_dir_all(root).ok();
2129 }
2130
2131 #[cfg(unix)]
2132 #[test]
2133 fn fs_initial_scan_skips_symlinked_dirs() {
2134 let root = temp_dir("scan-symlink");
2135 let real = root.join("real");
2136 fs::create_dir_all(&real).expect("real dir can be created");
2137 fs::write(real.join("kept.txt"), "kept").expect("kept file");
2138 std::os::unix::fs::symlink(&root, root.join("loop")).expect("symlink can be created");
2139 let opts = FromFsWatchOptions {
2140 recursive: true,
2141 initial_scan: true,
2142 include: vec!["*.txt".to_owned()],
2143 ..FromFsWatchOptions::default()
2144 };
2145 let events = initial_scan_events(&[watch_root(root.clone())], &opts);
2146
2147 let rels: Vec<_> = events
2148 .into_iter()
2149 .map(|event| event.relative_path)
2150 .collect();
2151 assert_eq!(rels, vec![PathBuf::from("real").join("kept.txt")]);
2152 fs::remove_dir_all(root).ok();
2153 }
2154
2155 #[test]
2156 fn fs_watch_cleanup_is_one_shot_and_cancels_driver_work() {
2157 let active = Rc::new(Cell::new(true));
2158 let canceled = Rc::new(Cell::new(0usize));
2159 let canceled_once = canceled.clone();
2160 let cancel_slot: Rc<RefCell<Option<DriverCancel>>> =
2161 Rc::new(RefCell::new(Some(Box::new(move || {
2162 canceled_once.set(canceled_once.get() + 1)
2163 }))));
2164 let watcher_slot: Rc<RefCell<Option<RecommendedWatcher>>> = Rc::new(RefCell::new(None));
2165
2166 cleanup_fs_watch(&active, &cancel_slot, &watcher_slot);
2167 cleanup_fs_watch(&active, &cancel_slot, &watcher_slot);
2168
2169 assert!(!active.get());
2170 assert_eq!(canceled.get(), 1);
2171 assert!(cancel_slot.borrow().is_none());
2172 }
2173}