Skip to main content

graphrefly/
sources.rs

1//! Source factories (D43/D40/D111).
2//!
3//! Sync sources run directly in the source body. Async/time sources stay at the
4//! source/driver boundary: they schedule work on the graph-local driver and emit
5//! later through `DeferredCtx`, preserving the sync wave core.
6
7use 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
35/// of: emit one value and COMPLETE on activation.
36pub 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
43/// from_iter: emit every item in order, then COMPLETE, on activation.
44pub 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
55/// empty: COMPLETE immediately with no DATA.
56pub fn empty<T: 'static>() -> Operator<T> {
57    Operator::new("empty", |ctx| {
58        ctx.down(vec![Message::Complete]);
59    })
60}
61
62/// never: activate and remain silent until deactivation.
63pub fn never<T: 'static>() -> Operator<T> {
64    Operator::new("never", |_| {})
65}
66
67/// throw_error: terminate with ERROR on activation.
68pub 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/// Host-boundary result for [`first_sync_value_from`] and [`single_sync_value_from`].
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum SyncValueFromError {
78    /// The node completed before emitting a DATA value.
79    CompleteWithoutData,
80    /// The node emitted a protocol ERROR before the helper could return a value.
81    Error(String),
82    /// The node stayed live after the synchronous subscribe/current-drain window.
83    Pending,
84    /// The node synchronously tore down without a successful value result.
85    Teardown,
86    /// The node emitted more than one DATA value before completing.
87    TooManyValues,
88    /// The typed node delivered DATA that could not be downcast to `T`.
89    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
107/// Read the first DATA value delivered during the synchronous subscribe window.
108///
109/// This is a host-boundary convenience for already-synchronous sources such as
110/// [`of`] and [`from_iter`]. It does not wait, spawn, poll, or install a hidden
111/// scheduler: if the node remains live without a DATA value, it returns
112/// [`SyncValueFromError::Pending`] after immediately unsubscribing.
113pub 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
127/// Read exactly one DATA value from a synchronously completing source.
128///
129/// The helper returns [`SyncValueFromError::Pending`] if the node does not
130/// complete during the synchronous subscribe/current-drain window. It is not a
131/// Promise/Future analogue and owns no runtime.
132pub 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
247/// run_process: execute one process via the graph-local environment process driver.
248///
249/// Completion emits one [`ProcessResult`] DATA and COMPLETE. Non-zero process
250/// exits remain DATA. Driver/spawn failures become protocol ERROR.
251pub 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
259/// from_process: source-name alias for [`run_process`].
260pub 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
269/// Configurable form of [`run_process`].
270pub 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
325/// Creates or computes `from_http`.
326pub fn from_http(url: impl Into<String>) -> Operator<HttpResponse> {
327    from_http_with_options(HttpRequest::get(url))
328}
329
330/// Creates or computes `from_http_with_options`.
331pub 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
379/// Creates or computes `from_sse`.
380pub fn from_sse(url: impl Into<String>) -> Operator<SseEvent> {
381    from_sse_with_options(SseRequest::new(url))
382}
383
384/// Creates or computes `from_sse_with_options`.
385pub 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
716/// Creates or computes `from_websocket`.
717pub fn from_websocket(url: impl Into<String>) -> Operator<WebSocketEvent> {
718    from_websocket_with_options(WebSocketRequest::new(url))
719}
720
721/// Creates or computes `from_websocket_with_options`.
722pub 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
776/// from_webhook: register an inbound webhook bridge through the graph environment.
777///
778/// The host HTTP framework/server owns routing and calls the installed driver.
779/// GraphReFly exposes the inbound payload as DATA while keeping async work at
780/// the environment boundary (D130/D131).
781pub fn from_webhook(id: impl Into<String>) -> Operator<WebhookEvent> {
782    from_webhook_with_options(WebhookRegistration::new(id))
783}
784
785/// Configurable form of [`from_webhook`].
786pub 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/// Filesystem event kind emitted by [`from_fs_watch`].
841#[derive(Debug, Clone, PartialEq, Eq)]
842pub enum FsEventKind {
843    /// `Change` variant.
844    Change,
845    /// `Rename` variant.
846    Rename,
847    /// `Create` variant.
848    Create,
849    /// `Delete` variant.
850    Delete,
851}
852
853/// Filesystem event emitted by [`from_fs_watch`].
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct FsEvent {
856    /// `kind` field for kind.
857    pub kind: FsEventKind,
858    /// `path` field for path.
859    pub path: PathBuf,
860    /// `root` field for root.
861    pub root: PathBuf,
862    /// `relative_path` field for relative path.
863    pub relative_path: PathBuf,
864}
865
866/// Options for [`from_fs_watch_with_options`].
867#[derive(Debug, Clone)]
868pub struct FromFsWatchOptions {
869    /// `recursive` field for recursive.
870    pub recursive: bool,
871    /// `debounce_ms` field for debounce ms.
872    pub debounce_ms: u64,
873    /// `initial_scan` field for initial scan.
874    pub initial_scan: bool,
875    /// `include` field for include.
876    pub include: Vec<String>,
877    /// `exclude` field for exclude.
878    pub exclude: Vec<String>,
879}
880
881/// Minimal five-field cron schedule: minute hour day-of-month month day-of-week.
882#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct CronSchedule {
884    /// `minutes` field for minutes.
885    pub minutes: BTreeSet<u8>,
886    /// `hours` field for hours.
887    pub hours: BTreeSet<u8>,
888    /// `days_of_month` field for days of month.
889    pub days_of_month: BTreeSet<u8>,
890    /// `months` field for months.
891    pub months: BTreeSet<u8>,
892    /// Sunday = 0, matching common five-field cron and JavaScript Date#getDay.
893    pub days_of_week: BTreeSet<u8>,
894}
895
896/// Fieldized instant used by [`matches_cron`].
897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
898pub struct CronInstant {
899    /// `year` field for year.
900    pub year: i32,
901    /// `month` field for month.
902    pub month: u8,
903    /// `day_of_month` field for day of month.
904    pub day_of_month: u8,
905    /// `hour` field for hour.
906    pub hour: u8,
907    /// `minute` field for minute.
908    pub minute: u8,
909    /// Sunday = 0.
910    pub day_of_week: u8,
911}
912
913impl CronInstant {
914    #[must_use]
915    /// Creates or computes `new`.
916    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/// Value emitted by [`from_cron`].
936#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct CronTick {
938    /// `instant` field for instant.
939    pub instant: CronInstant,
940    /// Decimal nanoseconds since the Unix epoch.
941    pub timestamp_ns: String,
942}
943
944impl CronTick {
945    #[must_use]
946    /// Creates or computes `new`.
947    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)]
956/// `FromCronOptions` data container.
957pub struct FromCronOptions {
958    /// `tick_ms` field for tick ms.
959    pub tick_ms: u64,
960    /// `now` field for now.
961    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/// Error returned by [`parse_cron`].
974#[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)]
996/// `GitHookType` variants.
997pub enum GitHookType {
998    /// `PostCommit` variant.
999    PostCommit,
1000}
1001
1002/// Git event emitted by [`from_git_hook`].
1003#[derive(Debug, Clone, PartialEq, Eq)]
1004pub struct GitEvent {
1005    /// `hook` field for hook.
1006    pub hook: GitHookType,
1007    /// `commit` field for commit.
1008    pub commit: String,
1009    /// `files` field for files.
1010    pub files: Vec<String>,
1011    /// `message` field for message.
1012    pub message: String,
1013    /// `author` field for author.
1014    pub author: String,
1015    /// `timestamp_ns` field for timestamp ns.
1016    pub timestamp_ns: String,
1017}
1018
1019#[derive(Debug, Clone)]
1020/// `FromGitHookOptions` data container.
1021pub struct FromGitHookOptions {
1022    /// `poll_ms` field for poll ms.
1023    pub poll_ms: u64,
1024    /// `include` field for include.
1025    pub include: Vec<String>,
1026    /// `exclude` field for exclude.
1027    pub exclude: Vec<String>,
1028    /// `max_consecutive_errors` field for max consecutive errors.
1029    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
1073/// from_fs_watch: filesystem watcher source.
1074///
1075/// Filesystem callbacks enqueue host events; the graph-local async driver drains
1076/// that queue back through `DeferredCtx`, keeping graph state on the local
1077/// source boundary. The visible factory name is `fromFSWatch`, matching the
1078/// clean-slate TS source catalog without making this a parity surface.
1079pub 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
1086/// Configurable form of [`from_fs_watch`].
1087pub 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
1502/// Parse a standard five-field cron expression.
1503pub 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/// Test whether an instant matches a parsed five-field cron schedule.
1521#[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
1591/// from_cron: emit a [`CronTick`] when the schedule matches the current minute.
1592///
1593/// The default clock uses UTC `SystemTime`; pass [`FromCronOptions::now`] for a
1594/// domain-local clock or deterministic tests. Each matching minute emits at most
1595/// once. Requires a graph-local driver (D111); missing driver reports ERROR on
1596/// activation.
1597pub fn from_cron(expr: &str) -> Operator<CronTick> {
1598    from_cron_with_options(expr, FromCronOptions::default())
1599}
1600
1601/// Configurable form of [`from_cron`].
1602pub 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
1788/// from_git_hook: poll a local Git repository and emit post-commit events.
1789///
1790/// The first successful poll records a baseline and emits nothing; later HEAD
1791/// changes emit [`GitEvent`]. Git process calls are confined to this source
1792/// boundary and its graph-local driver callback (B72/D111).
1793pub 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
1800/// Configurable form of [`from_git_hook`].
1801pub 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
1918/// timer: one tick (`0`) after `ms`, then COMPLETE.
1919///
1920/// Requires a graph-local driver (D111); missing driver reports ERROR on activation.
1921pub fn timer(ms: u64) -> Operator<u64> {
1922    timer_source("timer", ms)
1923}
1924
1925/// from_timer: stable source-name alias for [`timer`].
1926///
1927/// Preserves the real factory name (`fromTimer`) in describe/render output.
1928pub fn from_timer(ms: u64) -> Operator<u64> {
1929    timer_source("fromTimer", ms)
1930}
1931
1932/// interval: ticks `0, 1, 2, ...` every `ms` until deactivation.
1933///
1934/// Requires a graph-local driver (D111); missing driver reports ERROR on activation.
1935pub 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
1963/// future_local: run a fresh single-thread local fallible future on activation.
1964///
1965/// `Ok(value)` emits DATA then COMPLETE; `Err(error)` emits ERROR. A plain Rust
1966/// `Future<Output = T>` has no rejection channel, so Rust async sources use the
1967/// fallible `Result` shape as the protocol error bridge.
1968pub 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
2000/// stream_local: pump a fresh single-thread local fallible stream through the
2001/// graph-local driver. Every `Ok(item)` becomes DATA; stream exhaustion emits
2002/// COMPLETE; the first `Err(error)` emits ERROR and terminates the source.
2003pub 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}