Skip to main content

camel_component_file/
lib.rs

1//! File system component for rust-camel — polls directories for new or changed
2//! files as consumer, writes exchange bodies to files as producer.
3//!
4//! Main types: `FileBundle`, `FileComponent`, `FileConsumer`, `FileProducer`.
5//! Main modules: `bundle`.
6
7mod atomic_write;
8pub mod bundle;
9pub mod health;
10mod poll_logic;
11mod polling_consumer;
12
13use poll_logic::poll_directory;
14
15pub use bundle::FileBundle;
16pub use health::FileHealthCheck;
17
18use std::collections::HashSet;
19use std::future::Future;
20use std::path::PathBuf;
21use std::pin::Pin;
22use std::str::FromStr;
23use std::sync::Arc;
24use std::task::{Context, Poll};
25use std::time::Duration;
26
27use async_trait::async_trait;
28use dashmap::DashMap;
29use regex::Regex;
30use tokio::fs;
31use tokio::fs::OpenOptions;
32use tokio::io;
33use tokio::io::AsyncWriteExt;
34use tokio::time;
35use tower::Service;
36use tracing::{debug, warn};
37
38use camel_component_api::{Body, BoxProcessor, CamelError, Exchange};
39use camel_component_api::{
40    Component, ComponentMetadata, Consumer, ConsumerContext, Endpoint, PollingConsumer,
41    ProducerContext, UriOption,
42};
43use camel_component_api::{UriConfig, parse_uri};
44use camel_language_api::Language;
45use camel_language_simple::SimpleLanguage;
46
47// ---------------------------------------------------------------------------
48// TempFileGuard — RAII cleanup for temp files (panic-safe)
49// ---------------------------------------------------------------------------
50
51/// RAII guard that ensures temp file cleanup even on panic.
52///
53/// When dropped, removes the file at `path` unless `disarm` is set to true.
54/// This protects against temp file leaks if `io::copy` panics mid-write.
55pub(crate) struct TempFileGuard {
56    path: PathBuf,
57    disarm: bool,
58}
59
60impl TempFileGuard {
61    pub(crate) fn new(path: PathBuf) -> Self {
62        Self {
63            path,
64            disarm: false,
65        }
66    }
67
68    /// Call after successful rename to prevent cleanup.
69    pub(crate) fn disarm(&mut self) {
70        self.disarm = true;
71    }
72}
73
74impl Drop for TempFileGuard {
75    fn drop(&mut self) {
76        if !self.disarm {
77            // Best-effort cleanup; ignore errors (file may not exist)
78            let _ = std::fs::remove_file(&self.path);
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// FileExistStrategy
85// ---------------------------------------------------------------------------
86
87/// Strategy for handling existing files when writing.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum FileExistStrategy {
90    /// Overwrite existing file (default).
91    #[default]
92    Override,
93    /// Append to existing file.
94    Append,
95    /// Fail if file exists.
96    Fail,
97    /// Skip write if file already exists.
98    Ignore,
99    TryRename,
100}
101
102impl FromStr for FileExistStrategy {
103    type Err = String;
104
105    fn from_str(s: &str) -> Result<Self, Self::Err> {
106        match s {
107            "Override" | "override" => Ok(FileExistStrategy::Override),
108            "Append" | "append" => Ok(FileExistStrategy::Append),
109            "Fail" | "fail" => Ok(FileExistStrategy::Fail),
110            "Ignore" | "ignore" => Ok(FileExistStrategy::Ignore),
111            "TryRename" | "tryRename" => Ok(FileExistStrategy::TryRename),
112            other => Err(format!("unknown FileExistStrategy: {other}")),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum ReadLockStrategy {
119    #[default]
120    None,
121    InProcess,
122    Rename,
123}
124
125impl FromStr for ReadLockStrategy {
126    type Err = String;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        match s {
130            "None" | "none" => Ok(Self::None),
131            "InProcess" | "inProcess" | "inprocess" => Ok(Self::InProcess),
132            "Rename" | "rename" => Ok(Self::Rename),
133            other => Err(format!("unknown ReadLockStrategy: {other}")),
134        }
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub enum IdempotentKey {
140    #[default]
141    None,
142    FileName,
143    FilePath,
144    FileSize,
145    /// Lightweight fingerprint using file path + last-modified timestamp (not a cryptographic hash).
146    Digest,
147}
148
149impl FromStr for IdempotentKey {
150    type Err = String;
151
152    fn from_str(s: &str) -> Result<Self, Self::Err> {
153        match s {
154            "None" | "none" => Ok(Self::None),
155            "FileName" | "fileName" | "filename" => Ok(Self::FileName),
156            "FilePath" | "filePath" | "filepath" => Ok(Self::FilePath),
157            "FileSize" | "fileSize" | "filesize" => Ok(Self::FileSize),
158            "Digest" | "digest" => Ok(Self::Digest),
159            other => Err(format!("unknown IdempotentKey: {other}")),
160        }
161    }
162}
163
164// ---------------------------------------------------------------------------
165// FileGlobalConfig
166// ---------------------------------------------------------------------------
167
168/// Global configuration for File component.
169/// Supports serde deserialization with defaults and builder methods.
170/// These are the fallback defaults when URI params are not set.
171#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
172#[serde(default)]
173pub struct FileGlobalConfig {
174    pub delay_ms: u64,
175    pub initial_delay_ms: u64,
176    pub read_timeout_ms: u64,
177    pub write_timeout_ms: u64,
178}
179
180impl Default for FileGlobalConfig {
181    fn default() -> Self {
182        Self {
183            delay_ms: 500,
184            initial_delay_ms: 1_000,
185            read_timeout_ms: 30_000,
186            write_timeout_ms: 30_000,
187        }
188    }
189}
190
191impl FileGlobalConfig {
192    pub fn new() -> Self {
193        Self::default()
194    }
195    pub fn with_delay_ms(mut self, v: u64) -> Self {
196        self.delay_ms = v;
197        self
198    }
199    pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
200        self.initial_delay_ms = v;
201        self
202    }
203    pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
204        self.read_timeout_ms = v;
205        self
206    }
207    pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
208        self.write_timeout_ms = v;
209        self
210    }
211}
212
213// ---------------------------------------------------------------------------
214// CompiledFilters — precompiled file selection predicates
215// ---------------------------------------------------------------------------
216
217#[derive(Debug, Clone, Default)]
218pub(crate) struct CompiledFilters {
219    pub include_re: Option<Regex>,
220    pub exclude_re: Option<Regex>,
221    pub ant_include_patterns: Option<Vec<glob::Pattern>>,
222    pub ant_exclude_patterns: Option<Vec<glob::Pattern>>,
223    pub include_exts: Option<Vec<String>>,
224    pub exclude_exts: Option<Vec<String>>,
225}
226
227impl CompiledFilters {
228    pub fn compile(config: &FileConfig) -> Result<Self, CamelError> {
229        let include_re = config
230            .include
231            .as_deref()
232            .map(|p| {
233                Regex::new(p).map_err(|e| CamelError::Config(format!("invalid include regex: {e}")))
234            })
235            .transpose()?;
236        let exclude_re = config
237            .exclude
238            .as_deref()
239            .map(|p| {
240                Regex::new(p).map_err(|e| CamelError::Config(format!("invalid exclude regex: {e}")))
241            })
242            .transpose()?;
243        let ant_include_patterns = config
244            .ant_include
245            .as_deref()
246            .map(|list| {
247                list.split(',')
248                    .map(|s| {
249                        s.trim().parse::<glob::Pattern>().map_err(|e| {
250                            CamelError::Config(format!("invalid antInclude pattern '{s}': {e}"))
251                        })
252                    })
253                    .collect::<Result<Vec<_>, _>>()
254            })
255            .transpose()?;
256        let ant_exclude_patterns = config
257            .ant_exclude
258            .as_deref()
259            .map(|list| {
260                list.split(',')
261                    .map(|s| {
262                        s.trim().parse::<glob::Pattern>().map_err(|e| {
263                            CamelError::Config(format!("invalid antExclude pattern '{s}': {e}"))
264                        })
265                    })
266                    .collect::<Result<Vec<_>, _>>()
267            })
268            .transpose()?;
269        let include_exts = config
270            .include_ext
271            .as_deref()
272            .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
273        let exclude_exts = config
274            .exclude_ext
275            .as_deref()
276            .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
277        Ok(Self {
278            include_re,
279            exclude_re,
280            ant_include_patterns,
281            ant_exclude_patterns,
282            include_exts,
283            exclude_exts,
284        })
285    }
286}
287
288// ---------------------------------------------------------------------------
289// SortSpec — sort order for files (file:name, file:length, file:modified)
290// ---------------------------------------------------------------------------
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub(crate) enum SortField {
294    Name,
295    Length,
296    Modified,
297}
298
299#[derive(Debug, Clone)]
300pub(crate) struct SortGroup {
301    pub field: SortField,
302    pub reverse: bool,
303    pub ignore_case: bool,
304}
305
306#[derive(Debug, Clone)]
307pub(crate) struct SortSpec {
308    pub groups: Vec<SortGroup>,
309}
310
311impl FromStr for SortSpec {
312    type Err = String;
313
314    fn from_str(s: &str) -> Result<Self, Self::Err> {
315        let mut groups = Vec::new();
316        for raw_group in s.split(';') {
317            let trimmed = raw_group.trim();
318            if trimmed.is_empty() {
319                continue;
320            }
321            let mut reverse = false;
322            let mut ignore_case = false;
323            let mut remaining = trimmed;
324            let mut saw_ignore_case = false;
325
326            loop {
327                if let Some(rest) = remaining.strip_prefix("reverse:") {
328                    if saw_ignore_case {
329                        return Err("sortBy: reverse must precede ignoreCase".into());
330                    }
331                    reverse = true;
332                    remaining = rest;
333                    continue;
334                }
335                if let Some(rest) = remaining.strip_prefix("ignoreCase:") {
336                    ignore_case = true;
337                    saw_ignore_case = true;
338                    remaining = rest;
339                    continue;
340                }
341                break;
342            }
343
344            let field = match remaining {
345                "file:name" => SortField::Name,
346                "file:length" => SortField::Length,
347                "file:modified" => SortField::Modified,
348                other => return Err(format!("unsupported sortBy field: '{other}'")),
349            };
350
351            groups.push(SortGroup {
352                field,
353                reverse,
354                ignore_case,
355            });
356        }
357        if groups.is_empty() {
358            return Err("sortBy must have at least one group".into());
359        }
360        Ok(SortSpec { groups })
361    }
362}
363
364// ---------------------------------------------------------------------------
365// FileConfig
366// ---------------------------------------------------------------------------
367
368/// Private container for macro-derived `uri_options()` and `metadata()`.
369///
370/// Mirrors `FileConfig`'s URI-parsed fields using `FromStr`-compatible types
371/// (`u64` companion fields for Duration). `FileConfig` holds non-URI fields
372/// (the `Duration` fields derived from `_ms` fields, `directory` as path);
373/// metadata delegation targets this inner type.
374#[derive(Debug, Clone, UriConfig)]
375#[allow(dead_code)]
376#[uri_scheme = "file"]
377#[uri_config(
378    skip_impl,
379    metadata(
380        scheme = "file",
381        description = "Read/write files from a directory",
382        producer,
383        consumer
384    ),
385    crate = "camel_component_api"
386)]
387struct FileUriConfig {
388    #[allow(dead_code)]
389    _path: String,
390    #[uri_param(
391        name = "delay",
392        default = "500",
393        desc = "Delay between polls in milliseconds"
394    )]
395    delay_ms: u64,
396    #[uri_param(
397        name = "initialDelay",
398        default = "1000",
399        desc = "Initial delay before first poll in milliseconds"
400    )]
401    initial_delay_ms: u64,
402    #[uri_param(
403        default = "false",
404        desc = "Do not delete or move files after processing"
405    )]
406    noop: bool,
407    #[uri_param(default = "false", desc = "Delete files after processing")]
408    delete: bool,
409    #[uri_param(name = "move", desc = "Directory to move processed files to")]
410    move_to: Option<String>,
411    #[uri_param(name = "fileName", desc = "Fixed filename for producer")]
412    file_name: Option<String>,
413    #[uri_param(desc = "Regex pattern for including files")]
414    include: Option<String>,
415    #[uri_param(desc = "Regex pattern for excluding files")]
416    exclude: Option<String>,
417    #[uri_param(default = "false", desc = "Scan directories recursively")]
418    recursive: bool,
419    #[uri_param(
420        name = "fileExist",
421        kind = "enum:Override,Append,Fail,Ignore,TryRename",
422        default = "Override",
423        desc = "Strategy for handling existing files when writing"
424    )]
425    file_exist: FileExistStrategy,
426    #[uri_param(
427        name = "readLock",
428        kind = "enum:None,InProcess,Rename",
429        default = "None",
430        desc = "Read lock strategy for concurrent consumers"
431    )]
432    read_lock_strategy: ReadLockStrategy,
433    #[uri_param(
434        name = "idempotentKey",
435        kind = "enum:None,FileName,FilePath,FileSize,Digest",
436        default = "None",
437        desc = "In-memory idempotent key selector"
438    )]
439    idempotent_key: IdempotentKey,
440    #[uri_param(name = "doneFileName", desc = "Done marker filename pattern")]
441    done_file_name: Option<String>,
442    #[uri_param(desc = "Charset for string body encoding")]
443    charset: Option<String>,
444    #[uri_param(
445        name = "tempPrefix",
446        desc = "Prefix for temporary files during atomic writes"
447    )]
448    temp_prefix: Option<String>,
449    #[uri_param(
450        default = "false",
451        desc = "If true, fsync temp file and parent directory after atomic write"
452    )]
453    durable: bool,
454    #[uri_param(
455        name = "autoCreate",
456        default = "true",
457        desc = "Automatically create directories"
458    )]
459    auto_create: bool,
460    #[uri_param(
461        name = "startingDirectoryMustExist",
462        default = "false",
463        desc = "Verify the starting directory exists at startup"
464    )]
465    starting_directory_must_exist: bool,
466    #[uri_param(
467        name = "readTimeout",
468        default = "30000",
469        desc = "Read timeout in milliseconds"
470    )]
471    read_timeout_ms: u64,
472    #[uri_param(
473        name = "writeTimeout",
474        default = "30000",
475        desc = "Write timeout in milliseconds"
476    )]
477    write_timeout_ms: u64,
478    #[uri_param(name = "maxDepth", desc = "Maximum recursion depth for scanning")]
479    max_depth: usize,
480    #[uri_param(
481        name = "minDepth",
482        default = "0",
483        desc = "Minimum recursion depth for scanning"
484    )]
485    min_depth: usize,
486    #[uri_param(
487        name = "maxMessagesPerPoll",
488        default = "0",
489        desc = "Maximum messages per poll"
490    )]
491    max_messages_per_poll: i64,
492    #[uri_param(
493        name = "eagerMaxMessagesPerPoll",
494        default = "true",
495        desc = "Eagerly enforce maxMessagesPerPoll"
496    )]
497    eager_max_messages_per_poll: bool,
498    #[uri_param(
499        name = "antInclude",
500        desc = "Ant-style include pattern (comma-separated)"
501    )]
502    ant_include: Option<String>,
503    #[uri_param(
504        name = "antExclude",
505        desc = "Ant-style exclude pattern (comma-separated)"
506    )]
507    ant_exclude: Option<String>,
508    #[uri_param(
509        name = "includeExt",
510        desc = "File extensions to include (comma-separated)"
511    )]
512    include_ext: Option<String>,
513    #[uri_param(
514        name = "excludeExt",
515        desc = "File extensions to exclude (comma-separated)"
516    )]
517    exclude_ext: Option<String>,
518    #[uri_param(default = "false", desc = "Shuffle files before processing")]
519    shuffle: bool,
520    #[uri_param(name = "sortBy", desc = "Sort specification for file ordering")]
521    sort_spec: Option<SortSpec>,
522    #[uri_param(
523        name = "cleanupStaleTemps",
524        default = "true",
525        desc = "Sweep stale temp files at consumer startup"
526    )]
527    cleanup_stale_temps: bool,
528}
529
530/// Configuration for file component endpoints.
531///
532/// # Streaming
533///
534/// Both the file consumer and producer use **native streaming** with no RAM
535/// materialization:
536///
537/// - The **consumer** creates a `Body::Stream` backed by `tokio::fs::File` via
538///   `ReaderStream`. Files of any size are handled without loading them into memory.
539///
540/// - The **producer** writes via `tokio::io::copy` directly to a `tokio::fs::File`
541///   using `Body::into_async_read()`. Writes for the `Override` strategy are
542///   **atomic**: data is written to a temporary file first and renamed only on
543///   success, preventing partial files on failure.
544///
545/// # Write strategies (`fileExist` URI parameter)
546///
547/// | Value | Behavior |
548/// |-------|----------|
549/// | `Override` (default) | Atomic write via temp file + rename |
550/// | `Append` | Appends to existing file; non-atomic by nature |
551/// | `Fail` | Returns error if file already exists |
552/// | `Ignore` | Skip write if file already exists |
553#[derive(Debug, Clone)]
554pub struct FileConfig {
555    /// Directory path to read from or write to.
556    pub directory: String,
557
558    /// Polling delay in milliseconds (companion field for `delay`).
559    #[allow(dead_code)]
560    delay_ms: u64,
561
562    /// Polling delay as Duration.
563    pub delay: Duration,
564
565    /// Initial delay in milliseconds (companion field for `initial_delay`).
566    #[allow(dead_code)]
567    initial_delay_ms: u64,
568
569    /// Initial delay as Duration.
570    pub initial_delay: Duration,
571
572    /// If true, don't delete or move files after processing.
573    pub noop: bool,
574
575    /// If true, delete files after processing.
576    pub delete: bool,
577
578    /// Directory to move processed files to (only if not noop/delete).
579    /// Default is ".camel" when not specified and noop/delete are false.
580    move_to: Option<String>,
581
582    /// Fixed filename for producer (optional).
583    pub file_name: Option<String>,
584
585    /// Regex pattern for including files (consumer).
586    pub include: Option<String>,
587
588    /// Regex pattern for excluding files (consumer).
589    pub exclude: Option<String>,
590
591    /// Ant-style include pattern (comma-separated).
592    pub ant_include: Option<String>,
593
594    /// Ant-style exclude pattern (comma-separated).
595    pub ant_exclude: Option<String>,
596
597    /// File extensions to include (comma-separated).
598    pub include_ext: Option<String>,
599
600    /// File extensions to exclude (comma-separated).
601    pub exclude_ext: Option<String>,
602
603    /// Whether to scan directories recursively.
604    pub recursive: bool,
605
606    /// Strategy for handling existing files when writing.
607    pub file_exist: FileExistStrategy,
608
609    /// Read lock strategy for concurrent consumers.
610    pub read_lock_strategy: ReadLockStrategy,
611
612    /// In-memory idempotent key selector for consumer.
613    pub idempotent_key: IdempotentKey,
614
615    /// Done marker filename pattern created after successful write.
616    pub done_file_name: Option<String>,
617
618    /// Charset for string body encoding (UTF-8, ISO-8859-1).
619    pub charset: Option<String>,
620
621    /// Prefix for temporary files during atomic writes.
622    pub temp_prefix: Option<String>,
623
624    /// If true, fsync temp file and parent directory after atomic write, in the
625    /// correct order (temp → rename → parent). Crash-safe but slower. Opt-in via
626    /// `?durable=true`. Default false (preserves current latency characteristics).
627    pub durable: bool,
628
629    /// Whether to automatically create directories.
630    pub auto_create: bool,
631
632    /// If true, verify the starting directory exists at startup.
633    pub starting_directory_must_exist: bool,
634
635    /// Read timeout in milliseconds (companion field for `read_timeout`).
636    #[allow(dead_code)]
637    read_timeout_ms: u64,
638
639    /// Read timeout as Duration.
640    pub read_timeout: Duration,
641
642    /// Write timeout in milliseconds (companion field for `write_timeout`).
643    #[allow(dead_code)]
644    write_timeout_ms: u64,
645
646    /// Write timeout as Duration.
647    pub write_timeout: Duration,
648
649    pub max_depth: usize,
650    pub min_depth: usize,
651    pub max_messages_per_poll: i64,
652    pub eager_max_messages_per_poll: bool,
653    pub shuffle: bool,
654    pub(crate) sort_spec: Option<SortSpec>,
655
656    /// If true (default), sweep stale temp files at consumer startup.
657    /// Temp files older than `4 * write_timeout` (floor 60s) matching the
658    /// configured `temp_prefix` are removed. Leftover `.tmp.*` files from
659    /// SIGKILL or crash are cleaned up automatically.
660    pub cleanup_stale_temps: bool,
661}
662
663impl UriConfig for FileConfig {
664    fn scheme() -> &'static str {
665        "file"
666    }
667
668    fn from_uri(uri: &str) -> Result<Self, CamelError> {
669        let parts = parse_uri(uri)?;
670        Self::from_components(parts)
671    }
672
673    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
674        if parts.scheme != Self::scheme() {
675            return Err(CamelError::InvalidUri(format!(
676                "unsupported scheme '{}', expected '{}'",
677                parts.scheme,
678                Self::scheme()
679            )));
680        }
681
682        fn parse_bool_param(
683            params: &std::collections::HashMap<String, String>,
684            key: &str,
685            default: bool,
686        ) -> Result<bool, CamelError> {
687            match params.get(key) {
688                Some(v) => match v.to_lowercase().as_str() {
689                    "true" | "1" | "yes" => Ok(true),
690                    "false" | "0" | "no" => Ok(false),
691                    _ => Err(CamelError::InvalidUri(format!(
692                        "invalid value for {key}: invalid boolean value: '{v}'"
693                    ))),
694                },
695                None => Ok(default),
696            }
697        }
698
699        fn parse_u64_param(
700            params: &std::collections::HashMap<String, String>,
701            key: &str,
702            default: u64,
703        ) -> Result<u64, CamelError> {
704            match params.get(key) {
705                Some(v) => v
706                    .parse::<u64>()
707                    .map_err(|e| CamelError::InvalidUri(format!("invalid value for {key}: {e}"))),
708                None => Ok(default),
709            }
710        }
711
712        fn parse_enum_param<T: FromStr>(
713            params: &std::collections::HashMap<String, String>,
714            key: &str,
715            default: &str,
716        ) -> Result<T, CamelError>
717        where
718            T::Err: std::fmt::Display,
719        {
720            let raw = params.get(key).map(String::as_str).unwrap_or(default);
721            raw.parse::<T>().map_err(|e| {
722                CamelError::InvalidUri(format!("invalid value for parameter '{key}': {e}"))
723            })
724        }
725
726        let params = &parts.params;
727        let delay_ms = parse_u64_param(params, "delay", 500)?;
728        let initial_delay_ms = parse_u64_param(params, "initialDelay", 1000)?;
729        let read_timeout_ms = parse_u64_param(params, "readTimeout", 30_000)?;
730        let write_timeout_ms = parse_u64_param(params, "writeTimeout", 30_000)?;
731
732        let cfg = Self {
733            directory: parts.path,
734            delay_ms,
735            delay: Duration::from_millis(delay_ms),
736            initial_delay_ms,
737            initial_delay: Duration::from_millis(initial_delay_ms),
738            noop: parse_bool_param(params, "noop", false)?,
739            delete: parse_bool_param(params, "delete", false)?,
740            move_to: params.get("move").cloned(),
741            file_name: params.get("fileName").cloned(),
742            include: params.get("include").cloned(),
743            exclude: params.get("exclude").cloned(),
744            recursive: parse_bool_param(params, "recursive", false)?,
745            file_exist: parse_enum_param(params, "fileExist", "Override")?,
746            read_lock_strategy: parse_enum_param(params, "readLock", "None")?,
747            idempotent_key: parse_enum_param(params, "idempotentKey", "None")?,
748            done_file_name: params.get("doneFileName").cloned(),
749            charset: params.get("charset").cloned(),
750            temp_prefix: params.get("tempPrefix").cloned(),
751            durable: parse_bool_param(params, "durable", false)?,
752            auto_create: parse_bool_param(params, "autoCreate", true)?,
753            starting_directory_must_exist: parse_bool_param(
754                params,
755                "startingDirectoryMustExist",
756                false,
757            )?,
758            read_timeout_ms,
759            read_timeout: Duration::from_millis(read_timeout_ms),
760            write_timeout_ms,
761            write_timeout: Duration::from_millis(write_timeout_ms),
762            max_depth: parse_u64_param(params, "maxDepth", u64::MAX)? as usize,
763            min_depth: parse_u64_param(params, "minDepth", 0)? as usize,
764            max_messages_per_poll: params
765                .get("maxMessagesPerPoll")
766                .map(|v| {
767                    v.parse::<i64>().map_err(|e| {
768                        CamelError::InvalidUri(format!("invalid value for maxMessagesPerPoll: {e}"))
769                    })
770                })
771                .transpose()?
772                .unwrap_or(0),
773            eager_max_messages_per_poll: parse_bool_param(params, "eagerMaxMessagesPerPoll", true)?,
774            ant_include: params.get("antInclude").cloned(),
775            ant_exclude: params.get("antExclude").cloned(),
776            include_ext: params.get("includeExt").cloned(),
777            exclude_ext: params.get("excludeExt").cloned(),
778            shuffle: parse_bool_param(params, "shuffle", false)?,
779            sort_spec: params
780                .get("sortBy")
781                .map(|v| v.parse::<SortSpec>())
782                .transpose()
783                .map_err(|e| CamelError::InvalidUri(format!("invalid sortBy: {e}")))?,
784            cleanup_stale_temps: parse_bool_param(params, "cleanupStaleTemps", true)?,
785        };
786
787        cfg.validate()
788    }
789
790    fn validate(self) -> Result<Self, CamelError> {
791        // Reject path traversal in move_to
792        if let Some(ref move_to) = self.move_to
793            && path_contains_traversal(move_to)
794        {
795            return Err(CamelError::Config(format!(
796                "move_to contains path traversal component: {move_to}"
797            )));
798        }
799
800        if let Some(ref move_to) = self.move_to
801            && std::path::Path::new(move_to).is_absolute()
802        {
803            return Err(CamelError::InvalidUri(format!(
804                "move_to must be relative path within base directory: {move_to}"
805            )));
806        }
807
808        // Reject path traversal in temp_prefix
809        if let Some(ref temp_prefix) = self.temp_prefix
810            && path_contains_traversal(temp_prefix)
811        {
812            return Err(CamelError::Config(format!(
813                "temp_prefix contains path traversal component: {temp_prefix}"
814            )));
815        }
816
817        if let Some(ref temp_prefix) = self.temp_prefix
818            && !is_valid_temp_prefix(temp_prefix)
819        {
820            return Err(CamelError::Config(
821                "temp_prefix must be plain filename prefix (no path separators, absolute paths, or null bytes)".into(),
822            ));
823        }
824
825        if self.file_exist == FileExistStrategy::TryRename && self.temp_prefix.is_none() {
826            return Err(CamelError::Config(
827                "fileExist=TryRename requires tempPrefix to be set".into(),
828            ));
829        }
830
831        // Reject empty file_name
832        if let Some(ref file_name) = self.file_name {
833            if file_name.is_empty() {
834                return Err(CamelError::Config("file_name must not be empty".into()));
835            }
836            // Reject null bytes in file_name
837            if file_name.contains('\0') {
838                return Err(CamelError::Config(
839                    "file_name must not contain null bytes".into(),
840                ));
841            }
842        }
843
844        if self.min_depth > self.max_depth {
845            return Err(CamelError::Config(
846                "minDepth cannot be greater than maxDepth".into(),
847            ));
848        }
849
850        // If starting_directory_must_exist, verify directory exists
851        if self.starting_directory_must_exist {
852            let dir_path = std::path::Path::new(&self.directory);
853            if !dir_path.exists() {
854                return Err(CamelError::Config(format!(
855                    "starting directory does not exist: {}",
856                    self.directory
857                )));
858            }
859        }
860
861        // Apply conditional logic for move_to:
862        // - If noop or delete is true, move_to should be None
863        // - Otherwise, if move_to is None, default to ".camel"
864        let move_to = if self.noop || self.delete {
865            None
866        } else {
867            Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
868        };
869
870        Ok(Self { move_to, ..self })
871    }
872}
873
874impl FileConfig {
875    /// Component metadata for the file scheme, derived from `#[uri_param]`
876    /// annotations on `FileUriConfig`.
877    pub fn metadata() -> ComponentMetadata {
878        FileUriConfig::metadata()
879    }
880
881    /// Generated URI option definitions for the file scheme, derived from
882    /// `#[uri_param]` annotations on `FileUriConfig`.
883    pub fn uri_options() -> Vec<UriOption> {
884        FileUriConfig::uri_options()
885    }
886
887    /// Apply global config defaults. Since FileConfig uses a proc macro that bakes in
888    /// defaults, we compare Duration values against the known macro defaults to detect
889    /// "not explicitly set by user". Only overrides when current value == macro default.
890    ///
891    /// **Note**: If a user explicitly sets a URI param to its default value (e.g.,
892    /// `?delay=500`), it is indistinguishable from "not set" and will be overridden
893    /// by global config. This is a known limitation of the Duration comparison approach.
894    pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
895        if self.delay == Duration::from_millis(500) {
896            self.delay = Duration::from_millis(global.delay_ms);
897        }
898        if self.initial_delay == Duration::from_millis(1_000) {
899            self.initial_delay = Duration::from_millis(global.initial_delay_ms);
900        }
901        if self.read_timeout == Duration::from_millis(30_000) {
902            self.read_timeout = Duration::from_millis(global.read_timeout_ms);
903        }
904        if self.write_timeout == Duration::from_millis(30_000) {
905            self.write_timeout = Duration::from_millis(global.write_timeout_ms);
906        }
907    }
908}
909
910// ---------------------------------------------------------------------------
911// FileComponent
912// ---------------------------------------------------------------------------
913
914pub struct FileComponent {
915    config: Option<FileGlobalConfig>,
916}
917
918impl FileComponent {
919    pub fn new() -> Self {
920        Self { config: None }
921    }
922
923    pub fn with_config(config: FileGlobalConfig) -> Self {
924        Self {
925            config: Some(config),
926        }
927    }
928
929    pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
930        Self { config }
931    }
932}
933
934impl Default for FileComponent {
935    fn default() -> Self {
936        Self::new()
937    }
938}
939
940impl Component for FileComponent {
941    fn scheme(&self) -> &str {
942        "file"
943    }
944
945    fn metadata(&self) -> ComponentMetadata {
946        FileConfig::metadata()
947    }
948
949    fn create_endpoint(
950        &self,
951        uri: &str,
952        ctx: &dyn camel_component_api::ComponentContext,
953    ) -> Result<Box<dyn Endpoint>, CamelError> {
954        let mut config = FileConfig::from_uri(uri)?;
955        if let Some(ref global_config) = self.config {
956            config.apply_global_defaults(global_config);
957        }
958        let filters = CompiledFilters::compile(&config)?;
959        let dir_path = std::path::PathBuf::from(&config.directory);
960        let health_check = FileHealthCheck::new(dir_path.clone());
961        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
962        Ok(Box::new(FileEndpoint {
963            uri: uri.to_string(),
964            config,
965            filters,
966            in_process_locks: std::sync::Arc::new(DashMap::new()),
967            idempotent_repo: std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new())),
968        }))
969    }
970}
971
972// ---------------------------------------------------------------------------
973// FileEndpoint
974// ---------------------------------------------------------------------------
975
976struct FileEndpoint {
977    uri: String,
978    config: FileConfig,
979    filters: CompiledFilters,
980    in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
981    idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
982}
983
984impl Endpoint for FileEndpoint {
985    fn uri(&self) -> &str {
986        &self.uri
987    }
988
989    fn create_consumer(
990        &self,
991        rt: Arc<dyn camel_component_api::RuntimeObservability>,
992    ) -> Result<Box<dyn Consumer>, CamelError> {
993        Ok(Box::new(FileConsumer::new(
994            self.config.clone(),
995            self.filters.clone(),
996            self.in_process_locks.clone(),
997            self.idempotent_repo.clone(),
998            rt,
999        )))
1000    }
1001
1002    fn create_producer(
1003        &self,
1004        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1005        _ctx: &ProducerContext,
1006    ) -> Result<BoxProcessor, CamelError> {
1007        Ok(BoxProcessor::new(FileProducer {
1008            config: self.config.clone(),
1009        }))
1010    }
1011
1012    fn polling_consumer(&self) -> Option<Box<dyn PollingConsumer>> {
1013        Some(Box::new(polling_consumer::FilePollingConsumer::new(
1014            self.config.clone(),
1015            self.in_process_locks.clone(),
1016            self.idempotent_repo.clone(),
1017            self.filters.clone(),
1018        )))
1019    }
1020}
1021
1022// ---------------------------------------------------------------------------
1023// FileConsumer
1024// ---------------------------------------------------------------------------
1025
1026struct FileConsumer {
1027    config: FileConfig,
1028    filters: CompiledFilters,
1029    seen: HashSet<PathBuf>,
1030    in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
1031    idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
1032    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1033}
1034
1035impl FileConsumer {
1036    fn new(
1037        config: FileConfig,
1038        filters: CompiledFilters,
1039        in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
1040        idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
1041        runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1042    ) -> Self {
1043        Self {
1044            config,
1045            filters,
1046            seen: HashSet::new(),
1047            in_process_locks,
1048            idempotent_repo,
1049            runtime,
1050        }
1051    }
1052}
1053
1054#[async_trait]
1055impl Consumer for FileConsumer {
1056    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1057        let config = self.config.clone();
1058
1059        // Sweep stale temp files left by a previous crash/SIGKILL.
1060        if config.cleanup_stale_temps {
1061            let stale_age =
1062                Duration::from_secs((config.write_timeout.as_secs()).saturating_mul(4).max(60));
1063            let prefix = config
1064                .temp_prefix
1065                .as_deref()
1066                .unwrap_or(atomic_write::DEFAULT_TEMP_PREFIX);
1067            let dir = std::path::Path::new(&config.directory);
1068            if let Err(e) = atomic_write::sweep_stale_temps(dir, prefix, stale_age).await {
1069                warn!(
1070                    directory = config.directory,
1071                    error = %e,
1072                    "Stale temp sweep failed at startup"
1073                );
1074            }
1075        }
1076
1077        if !config.initial_delay.is_zero() {
1078            tokio::select! {
1079                _ = time::sleep(config.initial_delay) => {}
1080                _ = context.cancelled() => {
1081                    debug!(directory = config.directory, "File consumer cancelled during initial delay");
1082                    return Ok(());
1083                }
1084            }
1085        }
1086
1087        let mut interval = time::interval(config.delay);
1088
1089        loop {
1090            tokio::select! {
1091                _ = context.cancelled() => {
1092                    debug!(directory = config.directory, "File consumer received cancellation, stopping");
1093                    break;
1094                }
1095                _ = interval.tick() => {
1096                    if let Err(e) = poll_directory(
1097                        &config,
1098                        &context,
1099                        &self.runtime,
1100                        &self.filters,
1101                        &mut self.seen,
1102                        &self.in_process_locks,
1103                        &self.idempotent_repo,
1104                    ).await {
1105                        warn!(directory = config.directory, error = %e, "Error polling directory");
1106                    }
1107                }
1108            }
1109        }
1110
1111        Ok(())
1112    }
1113
1114    async fn stop(&mut self) -> Result<(), CamelError> {
1115        Ok(())
1116    }
1117}
1118
1119// ---------------------------------------------------------------------------
1120// Path validation for security
1121// ---------------------------------------------------------------------------
1122
1123/// Returns true if the path string contains a `..` component (path traversal).
1124fn path_contains_traversal(path: &str) -> bool {
1125    std::path::Path::new(path)
1126        .components()
1127        .any(|c| matches!(c, std::path::Component::ParentDir))
1128}
1129
1130fn is_valid_temp_prefix(prefix: &str) -> bool {
1131    !prefix.contains('\0')
1132        && !std::path::Path::new(prefix).is_absolute()
1133        && !prefix.contains(std::path::MAIN_SEPARATOR)
1134        && !prefix.contains('/')
1135        && !prefix.contains('\\')
1136}
1137
1138fn validate_path_is_within_base(
1139    base_dir: &std::path::Path,
1140    target_path: &std::path::Path,
1141) -> Result<(), CamelError> {
1142    // If both base and target exist, use strict canonicalize comparison.
1143    // Otherwise, do lexical traversal check (sufficient since config-time
1144    // validation already rejects '..' in fileName).
1145    if base_dir.exists() {
1146        let canonical_base = base_dir.canonicalize().map_err(|e| {
1147            CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
1148        })?;
1149
1150        let canonical_target = if target_path.exists() {
1151            target_path.canonicalize().map_err(|e| {
1152                CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
1153            })?
1154        } else if let Some(parent) = target_path.parent() {
1155            if parent.exists() {
1156                let canonical_parent = parent.canonicalize().map_err(|e| {
1157                    CamelError::ProcessorError(format!(
1158                        "Cannot canonicalize parent directory: {}",
1159                        e
1160                    ))
1161                })?;
1162                if let Some(filename) = target_path.file_name() {
1163                    canonical_parent.join(filename)
1164                } else {
1165                    return Err(CamelError::ProcessorError(
1166                        "Invalid target path: no filename".to_string(),
1167                    ));
1168                }
1169            } else {
1170                // Neither target nor its parent exist — use lexical traversal check.
1171                let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1172                    CamelError::ProcessorError(format!(
1173                        "Path '{}' is not under base '{}'",
1174                        target_path.display(),
1175                        base_dir.display()
1176                    ))
1177                })?;
1178                if path_contains_traversal(&rel.to_string_lossy()) {
1179                    return Err(CamelError::ProcessorError(format!(
1180                        "Path '{}' contains directory traversal",
1181                        target_path.display()
1182                    )));
1183                }
1184                return Ok(());
1185            }
1186        } else {
1187            return Err(CamelError::ProcessorError(
1188                "Invalid target path: no parent directory".to_string(),
1189            ));
1190        };
1191
1192        if !canonical_target.starts_with(&canonical_base) {
1193            return Err(CamelError::ProcessorError(format!(
1194                "Path '{}' is outside base directory '{}'",
1195                canonical_target.display(),
1196                canonical_base.display()
1197            )));
1198        }
1199    } else {
1200        // Base dir doesn't exist yet (auto_create case).
1201        // Lexical check: ensure no traversal in the relative portion.
1202        let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1203            CamelError::ProcessorError(format!(
1204                "Path '{}' is not under base '{}'",
1205                target_path.display(),
1206                base_dir.display()
1207            ))
1208        })?;
1209        let rel_str = rel.to_string_lossy();
1210        if path_contains_traversal(&rel_str) {
1211            return Err(CamelError::ProcessorError(format!(
1212                "Path '{}' contains directory traversal",
1213                target_path.display()
1214            )));
1215        }
1216    }
1217
1218    Ok(())
1219}
1220
1221// ---------------------------------------------------------------------------
1222// FileProducer
1223// ---------------------------------------------------------------------------
1224
1225#[derive(Clone)]
1226struct FileProducer {
1227    config: FileConfig,
1228}
1229
1230impl FileProducer {
1231    async fn resolve_filename(
1232        exchange: &Exchange,
1233        config: &FileConfig,
1234    ) -> Result<String, CamelError> {
1235        let raw = if let Some(name) = exchange
1236            .input
1237            .header("CamelFileName")
1238            .and_then(|v| v.as_str())
1239        {
1240            Some(name.to_string())
1241        } else {
1242            config.file_name.clone()
1243        };
1244
1245        match raw {
1246            Some(name) if name.contains("${") => {
1247                let lang = SimpleLanguage::new();
1248                let expr = lang.create_expression(&name).map_err(|e| {
1249                    CamelError::ProcessorError(format!(
1250                        "cannot parse fileName expression '{}': {e}",
1251                        name
1252                    ))
1253                })?;
1254                let val = expr.evaluate(exchange).await.map_err(|e| {
1255                    CamelError::ProcessorError(format!(
1256                        "cannot evaluate fileName expression '{}': {e}",
1257                        name
1258                    ))
1259                })?;
1260                match val {
1261                    serde_json::Value::String(s) => Ok(s),
1262                    other => Ok(other.to_string()),
1263                }
1264            }
1265            Some(name) => Ok(name),
1266            None => Err(CamelError::ProcessorError(
1267                "No filename specified: set CamelFileName header or fileName option".to_string(),
1268            )),
1269        }
1270    }
1271}
1272
1273impl Service<Exchange> for FileProducer {
1274    type Response = Exchange;
1275    type Error = CamelError;
1276    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1277
1278    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1279        Poll::Ready(Ok(()))
1280    }
1281
1282    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1283        let config = self.config.clone();
1284
1285        Box::pin(async move {
1286            let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1287            let body = exchange.input.body.clone();
1288
1289            let dir_path = std::path::Path::new(&config.directory);
1290            let target_path = dir_path.join(&file_name);
1291
1292            // 1. Security: validate path is within base directory
1293            validate_path_is_within_base(dir_path, &target_path)?;
1294
1295            // 2. Auto-create directories (after path validation)
1296            if config.auto_create
1297                && let Some(parent) = target_path.parent()
1298            {
1299                tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1300                    .await
1301                    .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1302                    .map_err(CamelError::from)?;
1303            }
1304
1305            // 3. Handle file-exist strategy
1306            match config.file_exist {
1307                FileExistStrategy::Fail => {
1308                    let mut file = tokio::time::timeout(
1309                        config.write_timeout,
1310                        OpenOptions::new()
1311                            .write(true)
1312                            .create_new(true)
1313                            .open(&target_path),
1314                    )
1315                    .await
1316                    .map_err(|_| {
1317                        CamelError::ProcessorError("Timeout opening file with create_new".into())
1318                    })?
1319                    .map_err(CamelError::from)?;
1320
1321                    write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1322                        .await?;
1323                    file.flush().await.map_err(CamelError::from)?;
1324                }
1325                FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1326                FileExistStrategy::Append => {
1327                    // Append: write directly without temp file (append is inherently non-atomic)
1328                    let mut file = tokio::time::timeout(
1329                        config.write_timeout,
1330                        OpenOptions::new()
1331                            .append(true)
1332                            .create(true)
1333                            .open(&target_path),
1334                    )
1335                    .await
1336                    .map_err(|_| {
1337                        CamelError::ProcessorError("Timeout opening file for append".into())
1338                    })?
1339                    .map_err(CamelError::from)?;
1340
1341                    write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1342                        .await?;
1343
1344                    file.flush().await.map_err(CamelError::from)?;
1345                }
1346                FileExistStrategy::TryRename => {
1347                    // TryRename requires an explicit tempPrefix (validated at config time).
1348                    // Delegates to atomic_write so both branches share one rename path.
1349                    let prefix = config.temp_prefix.as_deref().ok_or_else(|| {
1350                        CamelError::Config("fileExist=TryRename requires tempPrefix".into())
1351                    })?;
1352                    crate::atomic_write::atomic_write(
1353                        &target_path,
1354                        body,
1355                        Some(prefix),
1356                        config.durable,
1357                        config.write_timeout,
1358                        &config.charset,
1359                    )
1360                    .await?;
1361                }
1362                _ => {
1363                    // Override (and Fail when file doesn't exist): always atomic via temp file.
1364                    // Delegates to atomic_write — fixes Bug C (rc-o6o.3): temp path was previously
1365                    // computed by joining dir_path with prefix+full_file_name, producing a path
1366                    // whose parent did not exist for nested fileName values.
1367                    crate::atomic_write::atomic_write(
1368                        &target_path,
1369                        body,
1370                        config.temp_prefix.as_deref(),
1371                        config.durable,
1372                        config.write_timeout,
1373                        &config.charset,
1374                    )
1375                    .await?;
1376                }
1377            }
1378
1379            if let Some(done_pattern) = &config.done_file_name {
1380                let done_name = done_pattern.replace("${file:name}", &file_name);
1381                tokio::time::timeout(
1382                    config.write_timeout,
1383                    fs::write(dir_path.join(done_name), []),
1384                )
1385                .await
1386                .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1387                .map_err(CamelError::from)?;
1388            }
1389
1390            // 4. Set output header
1391            let abs_path = target_path
1392                .canonicalize()
1393                .unwrap_or_else(|_| target_path.clone())
1394                .to_string_lossy()
1395                .to_string();
1396            exchange
1397                .input
1398                .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1399
1400            debug!(
1401                file = %target_path.display(),
1402                correlation_id = %exchange.correlation_id(),
1403                "File written"
1404            );
1405
1406            Ok(exchange)
1407        })
1408    }
1409}
1410
1411pub(crate) async fn write_body_with_charset(
1412    body: Body,
1413    charset: &Option<String>,
1414    file: &mut fs::File,
1415    timeout: Duration,
1416) -> Result<(), CamelError> {
1417    match body {
1418        Body::Text(text) | Body::Xml(text) => {
1419            let bytes = encode_text_by_charset(&text, charset)?;
1420            tokio::time::timeout(timeout, file.write_all(&bytes))
1421                .await
1422                .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1423                .map_err(CamelError::from)?;
1424            Ok(())
1425        }
1426        other => {
1427            let mut reader = other.into_async_read()?;
1428            tokio::time::timeout(timeout, io::copy(&mut reader, file))
1429                .await
1430                .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1431                .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1432            Ok(())
1433        }
1434    }
1435}
1436
1437fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1438    let Some(charset) = charset.as_ref() else {
1439        return Ok(text.as_bytes().to_vec());
1440    };
1441
1442    if charset.eq_ignore_ascii_case("utf-8") {
1443        return Ok(text.as_bytes().to_vec());
1444    }
1445
1446    if charset.eq_ignore_ascii_case("iso-8859-1") {
1447        let mut out = Vec::with_capacity(text.len());
1448        for ch in text.chars() {
1449            let code = ch as u32;
1450            if code > 0xFF {
1451                return Err(CamelError::ProcessorError(format!(
1452                    "character '{ch}' cannot be encoded as ISO-8859-1"
1453                )));
1454            }
1455            out.push(code as u8);
1456        }
1457        return Ok(out);
1458    }
1459
1460    Err(CamelError::Config(format!(
1461        "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1462    )))
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use camel_component_api::test_support::PanicRuntimeObservability;
1468    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1469        std::sync::Arc::new(PanicRuntimeObservability)
1470    }
1471
1472    use super::*;
1473    use crate::poll_logic::{
1474        ModificationDetectingStream, apply_sort_and_limit, list_files, poll_one_file,
1475        scan_candidates,
1476    };
1477    use bytes::Bytes;
1478    use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1479    use futures::StreamExt;
1480    use std::time::Duration;
1481    use tokio_util::io::ReaderStream;
1482    use tokio_util::sync::CancellationToken;
1483
1484    fn test_producer_ctx() -> ProducerContext {
1485        ProducerContext::new()
1486    }
1487
1488    #[test]
1489    fn test_file_config_defaults() {
1490        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1491        assert_eq!(config.directory, "/tmp/inbox");
1492        assert_eq!(config.delay, Duration::from_millis(500));
1493        assert_eq!(config.initial_delay, Duration::from_millis(1000));
1494        assert!(!config.noop);
1495        assert!(!config.delete);
1496        assert_eq!(config.move_to, Some(".camel".to_string()));
1497        assert!(config.file_name.is_none());
1498        assert!(config.include.is_none());
1499        assert!(config.exclude.is_none());
1500        assert!(!config.recursive);
1501        assert_eq!(config.file_exist, FileExistStrategy::Override);
1502        assert!(config.temp_prefix.is_none());
1503        assert!(config.auto_create);
1504        // New timeout defaults
1505        assert_eq!(config.read_timeout, Duration::from_secs(30));
1506        assert_eq!(config.write_timeout, Duration::from_secs(30));
1507    }
1508
1509    #[test]
1510    fn file_config_durable_defaults_to_false() {
1511        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1512        assert!(!config.durable, "durable must default to false");
1513    }
1514
1515    #[test]
1516    fn file_config_durable_true_parsed_from_uri() {
1517        let config = FileConfig::from_uri("file:/tmp/inbox?durable=true").unwrap();
1518        assert!(config.durable, "durable=true must parse from URI");
1519    }
1520
1521    #[test]
1522    fn file_config_durable_rejects_invalid_value() {
1523        let result = FileConfig::from_uri("file:/tmp/inbox?durable=maybe");
1524        assert!(result.is_err(), "invalid durable value must be rejected");
1525    }
1526
1527    #[test]
1528    fn test_file_config_consumer_options() {
1529        let config = FileConfig::from_uri(
1530            "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1531        ).unwrap();
1532        assert_eq!(config.directory, "/data/input");
1533        assert_eq!(config.delay, Duration::from_millis(1000));
1534        assert_eq!(config.initial_delay, Duration::from_millis(2000));
1535        assert!(config.noop);
1536        assert!(config.recursive);
1537        assert_eq!(config.include, Some(".*\\.csv".to_string()));
1538    }
1539
1540    #[test]
1541    fn test_file_config_producer_options() {
1542        let config = FileConfig::from_uri(
1543            "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1544        )
1545        .unwrap();
1546        assert_eq!(config.file_exist, FileExistStrategy::Append);
1547        assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1548        assert!(!config.auto_create);
1549        assert_eq!(config.file_name, Some("out.txt".to_string()));
1550    }
1551
1552    #[test]
1553    fn test_file_config_delete_mode() {
1554        let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1555        assert!(config.delete);
1556        assert!(config.move_to.is_none());
1557    }
1558
1559    #[test]
1560    fn test_file_config_noop_mode() {
1561        let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1562        assert!(config.noop);
1563        assert!(config.move_to.is_none());
1564    }
1565
1566    #[test]
1567    fn test_file_config_wrong_scheme() {
1568        let result = FileConfig::from_uri("timer:tick");
1569        assert!(result.is_err());
1570    }
1571
1572    #[test]
1573    fn test_min_depth_greater_than_max_depth_rejected() {
1574        let result = FileConfig::from_uri("file:///tmp?minDepth=5&maxDepth=2");
1575        assert!(result.is_err());
1576        assert!(
1577            result
1578                .unwrap_err()
1579                .to_string()
1580                .contains("minDepth cannot be greater")
1581        );
1582    }
1583
1584    #[test]
1585    fn test_file_component_scheme() {
1586        let component = FileComponent::new();
1587        assert_eq!(component.scheme(), "file");
1588    }
1589
1590    #[test]
1591    fn test_file_component_creates_endpoint() {
1592        let component = FileComponent::new();
1593        let ctx = NoOpComponentContext;
1594        let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1595        assert!(endpoint.is_ok());
1596    }
1597
1598    // -----------------------------------------------------------------------
1599    // Consumer tests
1600    // -----------------------------------------------------------------------
1601
1602    #[tokio::test]
1603    async fn test_file_consumer_reads_files() {
1604        let dir = tempfile::tempdir().unwrap();
1605        let dir_path = dir.path().to_str().unwrap();
1606
1607        std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1608        std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1609
1610        let component = FileComponent::new();
1611        let ctx = NoOpComponentContext;
1612        let endpoint = component
1613            .create_endpoint(
1614                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1615                &ctx,
1616            )
1617            .unwrap();
1618        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1619
1620        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1621        let token = CancellationToken::new();
1622        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1623
1624        tokio::spawn(async move {
1625            consumer.start(ctx).await.unwrap();
1626        });
1627
1628        let mut received = Vec::new();
1629        let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1630            while let Some(envelope) = rx.recv().await {
1631                received.push(envelope.exchange);
1632                if received.len() == 2 {
1633                    break;
1634                }
1635            }
1636        })
1637        .await;
1638        token.cancel();
1639
1640        assert!(timeout.is_ok(), "Should have received 2 exchanges");
1641        assert_eq!(received.len(), 2);
1642
1643        for ex in &received {
1644            assert!(ex.input.header("CamelFileName").is_some());
1645            assert!(ex.input.header("CamelFileNameOnly").is_some());
1646            assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1647            assert!(ex.input.header("CamelFileLength").is_some());
1648            assert!(ex.input.header("CamelFileLastModified").is_some());
1649        }
1650    }
1651
1652    #[tokio::test]
1653    async fn noop_second_poll_does_not_re_emit_seen_files() {
1654        let dir = tempfile::tempdir().unwrap();
1655        let file_path = dir.path().join("test.txt");
1656        tokio::fs::write(&file_path, b"hello").await.unwrap();
1657
1658        let uri = format!(
1659            "file:{}?noop=true&initialDelay=0&delay=50",
1660            dir.path().display()
1661        );
1662        let config = FileConfig::from_uri(&uri).unwrap();
1663        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1664        let token = CancellationToken::new();
1665        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1666
1667        let filters = CompiledFilters::default();
1668        let mut seen = std::collections::HashSet::new();
1669        let in_process_locks = std::sync::Arc::new(DashMap::new());
1670        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1671
1672        poll_directory(
1673            &config,
1674            &ctx,
1675            &rt(),
1676            &filters,
1677            &mut seen,
1678            &in_process_locks,
1679            &idempotent_repo,
1680        )
1681        .await
1682        .unwrap();
1683        assert!(rx.try_recv().is_ok(), "first poll should emit file");
1684        assert!(rx.try_recv().is_err(), "should only emit once");
1685
1686        poll_directory(
1687            &config,
1688            &ctx,
1689            &rt(),
1690            &filters,
1691            &mut seen,
1692            &in_process_locks,
1693            &idempotent_repo,
1694        )
1695        .await
1696        .unwrap();
1697        assert!(
1698            rx.try_recv().is_err(),
1699            "second poll should not re-emit seen file"
1700        );
1701    }
1702
1703    #[tokio::test]
1704    async fn noop_new_files_picked_up_after_first_poll() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let file1 = dir.path().join("a.txt");
1707        tokio::fs::write(&file1, b"a").await.unwrap();
1708
1709        let uri = format!(
1710            "file:{}?noop=true&initialDelay=0&delay=50",
1711            dir.path().display()
1712        );
1713        let config = FileConfig::from_uri(&uri).unwrap();
1714        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1715        let token = CancellationToken::new();
1716        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1717
1718        let filters = CompiledFilters::default();
1719        let mut seen = std::collections::HashSet::new();
1720        let in_process_locks = std::sync::Arc::new(DashMap::new());
1721        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1722
1723        poll_directory(
1724            &config,
1725            &ctx,
1726            &rt(),
1727            &filters,
1728            &mut seen,
1729            &in_process_locks,
1730            &idempotent_repo,
1731        )
1732        .await
1733        .unwrap();
1734        let _ = rx.try_recv();
1735
1736        let file2 = dir.path().join("b.txt");
1737        tokio::fs::write(&file2, b"b").await.unwrap();
1738
1739        poll_directory(
1740            &config,
1741            &ctx,
1742            &rt(),
1743            &filters,
1744            &mut seen,
1745            &in_process_locks,
1746            &idempotent_repo,
1747        )
1748        .await
1749        .unwrap();
1750        assert!(
1751            rx.try_recv().is_ok(),
1752            "b.txt should be emitted on second poll"
1753        );
1754        assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1755    }
1756
1757    #[tokio::test]
1758    async fn test_file_consumer_include_filter() {
1759        let dir = tempfile::tempdir().unwrap();
1760        let dir_path = dir.path().to_str().unwrap();
1761
1762        std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1763        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1764
1765        let component = FileComponent::new();
1766        let ctx = NoOpComponentContext;
1767        let endpoint = component
1768            .create_endpoint(
1769                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1770                &ctx,
1771            )
1772            .unwrap();
1773        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1774
1775        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1776        let token = CancellationToken::new();
1777        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1778
1779        tokio::spawn(async move {
1780            consumer.start(ctx).await.unwrap();
1781        });
1782
1783        let mut received = Vec::new();
1784        let _ = tokio::time::timeout(Duration::from_millis(500), async {
1785            while let Some(envelope) = rx.recv().await {
1786                received.push(envelope.exchange);
1787                if received.len() == 1 {
1788                    break;
1789                }
1790            }
1791        })
1792        .await;
1793        token.cancel();
1794
1795        assert_eq!(received.len(), 1);
1796        let name = received[0]
1797            .input
1798            .header("CamelFileNameOnly")
1799            .and_then(|v| v.as_str())
1800            .unwrap();
1801        assert_eq!(name, "data.csv");
1802    }
1803
1804    #[tokio::test]
1805    async fn test_file_consumer_delete_mode() {
1806        let dir = tempfile::tempdir().unwrap();
1807        let dir_path = dir.path().to_str().unwrap();
1808
1809        std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1810
1811        let component = FileComponent::new();
1812        let ctx = NoOpComponentContext;
1813        let endpoint = component
1814            .create_endpoint(
1815                &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1816                &ctx,
1817            )
1818            .unwrap();
1819        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1820
1821        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1822        let token = CancellationToken::new();
1823        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1824
1825        tokio::spawn(async move {
1826            consumer.start(ctx).await.unwrap();
1827        });
1828
1829        let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1830        token.cancel();
1831
1832        tokio::time::sleep(Duration::from_millis(100)).await;
1833
1834        assert!(
1835            !dir.path().join("deleteme.txt").exists(),
1836            "File should be deleted"
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn test_file_consumer_move_mode() {
1842        let dir = tempfile::tempdir().unwrap();
1843        tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1844            .await
1845            .unwrap();
1846
1847        let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1848        let config = FileConfig::from_uri(&uri).unwrap();
1849        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1850        let token = CancellationToken::new();
1851        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1852
1853        let filters = CompiledFilters::default();
1854        let mut seen = std::collections::HashSet::new();
1855        let in_process_locks = std::sync::Arc::new(DashMap::new());
1856        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1857
1858        poll_directory(
1859            &config,
1860            &ctx,
1861            &rt(),
1862            &filters,
1863            &mut seen,
1864            &in_process_locks,
1865            &idempotent_repo,
1866        )
1867        .await
1868        .unwrap();
1869
1870        let ex = rx.try_recv().expect("should receive exchange");
1871        drop(ex);
1872
1873        assert!(
1874            !dir.path().join("moveme.txt").exists(),
1875            "Original file should be gone"
1876        );
1877        assert!(
1878            dir.path().join(".camel").join("moveme.txt").exists(),
1879            "File should be in .camel/"
1880        );
1881    }
1882
1883    #[tokio::test]
1884    async fn test_file_consumer_respects_cancellation() {
1885        let dir = tempfile::tempdir().unwrap();
1886        let dir_path = dir.path().to_str().unwrap();
1887
1888        let component = FileComponent::new();
1889        let ctx = NoOpComponentContext;
1890        let endpoint = component
1891            .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1892            .unwrap();
1893        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1894
1895        let (tx, _rx) = tokio::sync::mpsc::channel(16);
1896        let token = CancellationToken::new();
1897        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1898
1899        let handle = tokio::spawn(async move {
1900            consumer.start(ctx).await.unwrap();
1901        });
1902
1903        tokio::time::sleep(Duration::from_millis(150)).await;
1904        token.cancel();
1905
1906        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1907        assert!(
1908            result.is_ok(),
1909            "Consumer should have stopped after cancellation"
1910        );
1911    }
1912
1913    // -----------------------------------------------------------------------
1914    // Producer tests
1915    // -----------------------------------------------------------------------
1916
1917    #[tokio::test]
1918    async fn test_file_producer_writes_file() {
1919        use tower::ServiceExt;
1920
1921        let dir = tempfile::tempdir().unwrap();
1922        let dir_path = dir.path().to_str().unwrap();
1923
1924        let component = FileComponent::new();
1925        let ctx = NoOpComponentContext;
1926        let endpoint = component
1927            .create_endpoint(&format!("file:{dir_path}"), &ctx)
1928            .unwrap();
1929        let ctx = test_producer_ctx();
1930        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1931
1932        let mut exchange = Exchange::new(Message::new("file content"));
1933        exchange.input.set_header(
1934            "CamelFileName",
1935            serde_json::Value::String("output.txt".to_string()),
1936        );
1937
1938        let result = producer.oneshot(exchange).await.unwrap();
1939
1940        let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1941        assert_eq!(content, "file content");
1942
1943        assert!(result.input.header("CamelFileNameProduced").is_some());
1944    }
1945
1946    #[tokio::test]
1947    async fn test_file_producer_auto_create_dirs() {
1948        use tower::ServiceExt;
1949
1950        let dir = tempfile::tempdir().unwrap();
1951        let dir_path = dir.path().to_str().unwrap();
1952
1953        let component = FileComponent::new();
1954        let ctx = NoOpComponentContext;
1955        let endpoint = component
1956            .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1957            .unwrap();
1958        let ctx = test_producer_ctx();
1959        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1960
1961        let mut exchange = Exchange::new(Message::new("nested"));
1962        exchange.input.set_header(
1963            "CamelFileName",
1964            serde_json::Value::String("file.txt".to_string()),
1965        );
1966
1967        producer.oneshot(exchange).await.unwrap();
1968
1969        assert!(dir.path().join("sub/dir/file.txt").exists());
1970    }
1971
1972    #[tokio::test]
1973    async fn test_file_producer_file_exist_fail() {
1974        use tower::ServiceExt;
1975
1976        let dir = tempfile::tempdir().unwrap();
1977        let dir_path = dir.path().to_str().unwrap();
1978
1979        std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1980
1981        let component = FileComponent::new();
1982        let ctx = NoOpComponentContext;
1983        let endpoint = component
1984            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1985            .unwrap();
1986        let ctx = test_producer_ctx();
1987        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1988
1989        let mut exchange = Exchange::new(Message::new("new"));
1990        exchange.input.set_header(
1991            "CamelFileName",
1992            serde_json::Value::String("existing.txt".to_string()),
1993        );
1994
1995        let result = producer.oneshot(exchange).await;
1996        assert!(
1997            result.is_err(),
1998            "Should fail when file exists with Fail strategy"
1999        );
2000    }
2001
2002    #[tokio::test]
2003    async fn test_file_producer_file_exist_append() {
2004        use tower::ServiceExt;
2005
2006        let dir = tempfile::tempdir().unwrap();
2007        let dir_path = dir.path().to_str().unwrap();
2008
2009        std::fs::write(dir.path().join("append.txt"), "old").unwrap();
2010
2011        let component = FileComponent::new();
2012        let ctx = NoOpComponentContext;
2013        let endpoint = component
2014            .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
2015            .unwrap();
2016        let ctx = test_producer_ctx();
2017        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2018
2019        let mut exchange = Exchange::new(Message::new("new"));
2020        exchange.input.set_header(
2021            "CamelFileName",
2022            serde_json::Value::String("append.txt".to_string()),
2023        );
2024
2025        producer.oneshot(exchange).await.unwrap();
2026
2027        let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
2028        assert_eq!(content, "oldnew");
2029    }
2030
2031    #[tokio::test]
2032    async fn test_file_producer_temp_prefix() {
2033        use tower::ServiceExt;
2034
2035        let dir = tempfile::tempdir().unwrap();
2036        let dir_path = dir.path().to_str().unwrap();
2037
2038        let component = FileComponent::new();
2039        let ctx = NoOpComponentContext;
2040        let endpoint = component
2041            .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
2042            .unwrap();
2043        let ctx = test_producer_ctx();
2044        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2045
2046        let mut exchange = Exchange::new(Message::new("atomic write"));
2047        exchange.input.set_header(
2048            "CamelFileName",
2049            serde_json::Value::String("final.txt".to_string()),
2050        );
2051
2052        producer.oneshot(exchange).await.unwrap();
2053
2054        assert!(dir.path().join("final.txt").exists());
2055        assert!(!dir.path().join(".tmpfinal.txt").exists());
2056        let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
2057        assert_eq!(content, "atomic write");
2058    }
2059
2060    #[tokio::test]
2061    async fn file_producer_writes_nested_filename_override() {
2062        use tower::ServiceExt;
2063
2064        let dir = tempfile::tempdir().unwrap();
2065        let dir_path = dir.path().to_str().unwrap();
2066
2067        let component = FileComponent::new();
2068        let ctx = NoOpComponentContext;
2069        // Override is the default fileExist strategy.
2070        let endpoint = component
2071            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2072            .unwrap();
2073        let ctx = test_producer_ctx();
2074        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2075
2076        let mut exchange = Exchange::new(Message::new("nested body"));
2077        exchange.input.set_header(
2078            "CamelFileName",
2079            serde_json::Value::String("sub/dir/payload.bin".to_string()),
2080        );
2081
2082        producer.oneshot(exchange).await.unwrap();
2083
2084        let target = dir.path().join("sub").join("dir").join("payload.bin");
2085        assert!(target.exists(), "target file should exist at {:?}", target);
2086        let content = std::fs::read(&target).unwrap();
2087        assert_eq!(content, b"nested body");
2088        // No leftover temp file in the directory root (Bug C symptom: .tmp.sub/dir/payload.bin
2089        // path was being computed and the parent dir did not exist).
2090        assert!(
2091            !dir.path().join(".tmp.sub").exists(),
2092            "no stray temp path should be created at directory root"
2093        );
2094    }
2095
2096    #[tokio::test]
2097    async fn file_producer_writes_nested_filename_try_rename() {
2098        use tower::ServiceExt;
2099
2100        let dir = tempfile::tempdir().unwrap();
2101        let dir_path = dir.path().to_str().unwrap();
2102
2103        let component = FileComponent::new();
2104        let ctx = NoOpComponentContext;
2105        let endpoint = component
2106            .create_endpoint(
2107                &format!("file:{dir_path}?fileExist=TryRename&tempPrefix=.t."),
2108                &ctx,
2109            )
2110            .unwrap();
2111        let ctx = test_producer_ctx();
2112        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2113
2114        let mut exchange = Exchange::new(Message::new("try-rename nested"));
2115        exchange.input.set_header(
2116            "CamelFileName",
2117            serde_json::Value::String("deep/dir/x.bin".to_string()),
2118        );
2119
2120        producer.oneshot(exchange).await.unwrap();
2121
2122        let target = dir.path().join("deep").join("dir").join("x.bin");
2123        assert!(target.exists());
2124        assert_eq!(std::fs::read(&target).unwrap(), b"try-rename nested");
2125        // Temp file must not leak.
2126        assert!(
2127            !dir.path()
2128                .join("deep")
2129                .join("dir")
2130                .join(".t.x.bin")
2131                .exists(),
2132            "temp file should have been renamed away"
2133        );
2134    }
2135
2136    #[tokio::test]
2137    async fn file_producer_writes_nested_filename_fail_strategy() {
2138        use tower::ServiceExt;
2139
2140        let dir = tempfile::tempdir().unwrap();
2141        let dir_path = dir.path().to_str().unwrap();
2142
2143        let component = FileComponent::new();
2144        let ctx = NoOpComponentContext;
2145        let endpoint = component
2146            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2147            .unwrap();
2148        let ctx = test_producer_ctx();
2149        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2150
2151        let mut exchange = Exchange::new(Message::new("fail-strategy nested"));
2152        exchange.input.set_header(
2153            "CamelFileName",
2154            serde_json::Value::String("a/b/c.bin".to_string()),
2155        );
2156
2157        producer.oneshot(exchange).await.unwrap();
2158
2159        let target = dir.path().join("a").join("b").join("c.bin");
2160        assert!(target.exists());
2161        assert_eq!(std::fs::read(&target).unwrap(), b"fail-strategy nested");
2162    }
2163
2164    #[tokio::test]
2165    async fn test_file_producer_uses_filename_option() {
2166        use tower::ServiceExt;
2167
2168        let dir = tempfile::tempdir().unwrap();
2169        let dir_path = dir.path().to_str().unwrap();
2170
2171        let component = FileComponent::new();
2172        let ctx = NoOpComponentContext;
2173        let endpoint = component
2174            .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
2175            .unwrap();
2176        let ctx = test_producer_ctx();
2177        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2178
2179        let exchange = Exchange::new(Message::new("content"));
2180
2181        producer.oneshot(exchange).await.unwrap();
2182        assert!(dir.path().join("fixed.txt").exists());
2183    }
2184
2185    #[tokio::test]
2186    async fn test_file_producer_no_filename_errors() {
2187        use tower::ServiceExt;
2188
2189        let dir = tempfile::tempdir().unwrap();
2190        let dir_path = dir.path().to_str().unwrap();
2191
2192        let component = FileComponent::new();
2193        let ctx = NoOpComponentContext;
2194        let endpoint = component
2195            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2196            .unwrap();
2197        let ctx = test_producer_ctx();
2198        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2199
2200        let exchange = Exchange::new(Message::new("content"));
2201
2202        let result = producer.oneshot(exchange).await;
2203        assert!(result.is_err(), "Should error when no filename is provided");
2204    }
2205
2206    // -----------------------------------------------------------------------
2207    // Security tests - Path traversal protection
2208    // -----------------------------------------------------------------------
2209
2210    #[tokio::test]
2211    async fn test_file_producer_rejects_path_traversal_parent_directory() {
2212        use tower::ServiceExt;
2213
2214        let dir = tempfile::tempdir().unwrap();
2215        let dir_path = dir.path().to_str().unwrap();
2216
2217        // Create a subdirectory
2218        std::fs::create_dir(dir.path().join("subdir")).unwrap();
2219        std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2220
2221        let component = FileComponent::new();
2222        let ctx = NoOpComponentContext;
2223        let endpoint = component
2224            .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2225            .unwrap();
2226        let ctx = test_producer_ctx();
2227        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2228
2229        let mut exchange = Exchange::new(Message::new("malicious"));
2230        exchange.input.set_header(
2231            "CamelFileName",
2232            serde_json::Value::String("../secret.txt".to_string()),
2233        );
2234
2235        let result = producer.oneshot(exchange).await;
2236        assert!(result.is_err(), "Should reject path traversal attempt");
2237
2238        let err = result.unwrap_err();
2239        assert!(
2240            err.to_string().contains("outside"),
2241            "Error should mention path is outside base directory"
2242        );
2243    }
2244
2245    #[tokio::test]
2246    async fn test_file_producer_rejects_absolute_path_outside_base() {
2247        use tower::ServiceExt;
2248
2249        let dir = tempfile::tempdir().unwrap();
2250        let dir_path = dir.path().to_str().unwrap();
2251
2252        let component = FileComponent::new();
2253        let ctx = NoOpComponentContext;
2254        let endpoint = component
2255            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2256            .unwrap();
2257        let ctx = test_producer_ctx();
2258        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2259
2260        let mut exchange = Exchange::new(Message::new("malicious"));
2261        exchange.input.set_header(
2262            "CamelFileName",
2263            serde_json::Value::String("/etc/passwd".to_string()),
2264        );
2265
2266        let result = producer.oneshot(exchange).await;
2267        assert!(result.is_err(), "Should reject absolute path outside base");
2268    }
2269
2270    #[tokio::test]
2271    async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2272        use tower::ServiceExt;
2273
2274        let dir = tempfile::tempdir().unwrap();
2275        let dir_path = dir.path().to_str().unwrap();
2276
2277        let component = FileComponent::new();
2278        let ctx = NoOpComponentContext;
2279        let endpoint = component
2280            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2281            .unwrap();
2282        let ctx = test_producer_ctx();
2283        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2284
2285        let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2286        if outside_parent.exists() {
2287            std::fs::remove_dir_all(&outside_parent).unwrap();
2288        }
2289
2290        let mut exchange = Exchange::new(Message::new("malicious"));
2291        exchange.input.set_header(
2292            "CamelFileName",
2293            serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2294        );
2295
2296        let result = producer.oneshot(exchange).await;
2297        assert!(result.is_err(), "Should reject traversal path");
2298        assert!(
2299            !outside_parent.exists(),
2300            "Must not create outside directories before path validation"
2301        );
2302    }
2303
2304    #[cfg(unix)]
2305    #[tokio::test]
2306    async fn test_list_files_skips_symlink_cycle() {
2307        use std::os::unix::fs as unix_fs;
2308
2309        let dir = tempfile::tempdir().unwrap();
2310        let nested = dir.path().join("nested");
2311        std::fs::create_dir_all(&nested).unwrap();
2312        std::fs::write(nested.join("a.txt"), "x").unwrap();
2313        unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2314
2315        let files = list_files(dir.path(), true).await.unwrap();
2316        assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2317    }
2318
2319    // -----------------------------------------------------------------------
2320    // Large file streaming tests
2321    // -----------------------------------------------------------------------
2322
2323    #[tokio::test]
2324    #[ignore = "slow test: file polling (run with --ignored)"]
2325    async fn test_large_file_streaming_constant_memory() {
2326        use std::io::Write;
2327        use tempfile::NamedTempFile;
2328
2329        // Create a 150MB file (larger than 100MB limit)
2330        let mut temp_file = NamedTempFile::new().unwrap();
2331        let file_size = 150 * 1024 * 1024; // 150MB
2332        let chunk = vec![b'X'; 1024 * 1024]; // 1MB chunk
2333
2334        for _ in 0..150 {
2335            temp_file.write_all(&chunk).unwrap();
2336        }
2337        temp_file.flush().unwrap();
2338
2339        let dir = temp_file.path().parent().unwrap();
2340        let dir_path = dir.to_str().unwrap();
2341        let file_name = temp_file
2342            .path()
2343            .file_name()
2344            .unwrap()
2345            .to_str()
2346            .unwrap()
2347            .to_string();
2348
2349        // Read file as stream (should succeed with lazy evaluation)
2350        let component = FileComponent::new();
2351        let component_ctx = NoOpComponentContext;
2352        let endpoint = component
2353            .create_endpoint(
2354                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2355                &component_ctx,
2356            )
2357            .unwrap();
2358        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2359
2360        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2361        let token = CancellationToken::new();
2362        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2363
2364        tokio::spawn(async move {
2365            let _ = consumer.start(ctx).await;
2366        });
2367
2368        let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2369            rx.recv().await.unwrap().exchange
2370        })
2371        .await
2372        .expect("Should receive exchange");
2373        token.cancel();
2374
2375        // Verify body is a stream (not materialized)
2376        assert!(matches!(exchange.input.body, Body::Stream(_)));
2377
2378        // Verify we can read metadata without consuming
2379        if let Body::Stream(ref stream_body) = exchange.input.body {
2380            assert!(stream_body.metadata.size_hint.is_some());
2381            let size = stream_body.metadata.size_hint.unwrap();
2382            assert_eq!(size, file_size as u64);
2383        }
2384
2385        // Materializing should fail (exceeds 100MB limit)
2386        if let Body::Stream(stream_body) = exchange.input.body {
2387            let body = Body::Stream(stream_body);
2388            let result = body.into_bytes(100 * 1024 * 1024).await;
2389            assert!(result.is_err());
2390        }
2391
2392        // But we CAN read chunks one at a time (simulating line-by-line processing)
2393        // This demonstrates lazy evaluation - we don't need to load entire file
2394        let component2 = FileComponent::new();
2395        let endpoint2 = component2
2396            .create_endpoint(
2397                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2398                &component_ctx,
2399            )
2400            .unwrap();
2401        let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2402
2403        let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2404        let token2 = CancellationToken::new();
2405        let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2406
2407        tokio::spawn(async move {
2408            let _ = consumer2.start(ctx2).await;
2409        });
2410
2411        let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2412            rx2.recv().await.unwrap().exchange
2413        })
2414        .await
2415        .expect("Should receive exchange");
2416        token2.cancel();
2417
2418        if let Body::Stream(stream_body) = exchange2.input.body {
2419            let mut stream_lock = stream_body.stream.lock().await;
2420            let mut stream = stream_lock.take().unwrap();
2421
2422            // Read first chunk (size varies based on ReaderStream's buffer)
2423            if let Some(chunk_result) = stream.next().await {
2424                let chunk = chunk_result.unwrap();
2425                assert!(!chunk.is_empty());
2426                assert!(chunk.len() < file_size);
2427                // Memory usage is constant - we only have this chunk in memory, not 150MB
2428            }
2429        }
2430    }
2431
2432    // -----------------------------------------------------------------------
2433    // Streaming producer tests
2434    // -----------------------------------------------------------------------
2435
2436    #[tokio::test]
2437    async fn test_producer_writes_stream_body() {
2438        let dir = tempfile::tempdir().unwrap();
2439        let dir_path = dir.path().to_str().unwrap();
2440        let uri = format!("file:{dir_path}?fileName=out.txt");
2441
2442        let component = FileComponent::new();
2443        let ctx = NoOpComponentContext;
2444        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2445        let producer = endpoint
2446            .create_producer(rt(), &test_producer_ctx())
2447            .unwrap();
2448
2449        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2450            Ok(Bytes::from("hello ")),
2451            Ok(Bytes::from("streaming ")),
2452            Ok(Bytes::from("world")),
2453        ];
2454        let stream = futures::stream::iter(chunks);
2455        let body = Body::Stream(StreamBody {
2456            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2457            metadata: StreamMetadata {
2458                size_hint: None,
2459                content_type: None,
2460                origin: None,
2461            },
2462        });
2463
2464        let exchange = Exchange::new(Message::new(body));
2465        tower::ServiceExt::oneshot(producer, exchange)
2466            .await
2467            .unwrap();
2468
2469        let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2470            .await
2471            .unwrap();
2472        assert_eq!(content, "hello streaming world");
2473    }
2474
2475    #[tokio::test]
2476    async fn test_producer_stream_atomic_no_partial_on_error() {
2477        // If the stream errors mid-write, no file should exist at the target path
2478        let dir = tempfile::tempdir().unwrap();
2479        let dir_path = dir.path().to_str().unwrap();
2480        let uri = format!("file:{dir_path}?fileName=out.txt");
2481
2482        let component = FileComponent::new();
2483        let ctx = NoOpComponentContext;
2484        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2485        let producer = endpoint
2486            .create_producer(rt(), &test_producer_ctx())
2487            .unwrap();
2488
2489        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2490            Ok(Bytes::from("partial")),
2491            Err(CamelError::ProcessorError(
2492                "simulated stream error".to_string(),
2493            )),
2494        ];
2495        let stream = futures::stream::iter(chunks);
2496        let body = Body::Stream(StreamBody {
2497            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2498            metadata: StreamMetadata {
2499                size_hint: None,
2500                content_type: None,
2501                origin: None,
2502            },
2503        });
2504
2505        let exchange = Exchange::new(Message::new(body));
2506        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2507        assert!(
2508            result.is_err(),
2509            "expected error when stream fails mid-write"
2510        );
2511
2512        // Target file must NOT exist — write was aborted and temp file cleaned up
2513        assert!(
2514            !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2515            "partial file must not exist after failed write"
2516        );
2517
2518        // Temp file must also be cleaned up
2519        assert!(
2520            !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2521            "temp file must be cleaned up after failed write"
2522        );
2523    }
2524
2525    #[tokio::test]
2526    async fn test_producer_stream_append() {
2527        let dir = tempfile::tempdir().unwrap();
2528        let dir_path = dir.path().to_str().unwrap();
2529        let target = format!("{dir_path}/out.txt");
2530
2531        // Pre-create file with initial content
2532        tokio::fs::write(&target, b"line1\n").await.unwrap();
2533
2534        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2535        let component = FileComponent::new();
2536        let ctx = NoOpComponentContext;
2537        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2538        let producer = endpoint
2539            .create_producer(rt(), &test_producer_ctx())
2540            .unwrap();
2541
2542        let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2543        let stream = futures::stream::iter(chunks);
2544        let body = Body::Stream(StreamBody {
2545            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2546            metadata: StreamMetadata {
2547                size_hint: None,
2548                content_type: None,
2549                origin: None,
2550            },
2551        });
2552
2553        let exchange = Exchange::new(Message::new(body));
2554        tower::ServiceExt::oneshot(producer, exchange)
2555            .await
2556            .unwrap();
2557
2558        let content = tokio::fs::read_to_string(&target).await.unwrap();
2559        assert_eq!(content, "line1\nline2\n");
2560    }
2561
2562    #[tokio::test]
2563    async fn test_producer_stream_append_partial_on_error() {
2564        // Append is inherently non-atomic: if the stream errors mid-write,
2565        // the file will contain partial data. This test documents that behavior.
2566        let dir = tempfile::tempdir().unwrap();
2567        let dir_path = dir.path().to_str().unwrap();
2568        let target = format!("{dir_path}/out.txt");
2569
2570        // Pre-create file with initial content
2571        tokio::fs::write(&target, b"initial\n").await.unwrap();
2572
2573        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2574        let component = FileComponent::new();
2575        let ctx = NoOpComponentContext;
2576        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2577        let producer = endpoint
2578            .create_producer(rt(), &test_producer_ctx())
2579            .unwrap();
2580
2581        // Stream with an error in the middle
2582        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2583            Ok(Bytes::from("partial-")), // This will be written
2584            Err(CamelError::ProcessorError("stream error".to_string())), // This causes failure
2585            Ok(Bytes::from("never-written")), // This won't be reached
2586        ];
2587        let stream = futures::stream::iter(chunks);
2588        let body = Body::Stream(StreamBody {
2589            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2590            metadata: StreamMetadata {
2591                size_hint: None,
2592                content_type: None,
2593                origin: None,
2594            },
2595        });
2596
2597        let exchange = Exchange::new(Message::new(body));
2598        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2599
2600        // 1. Producer must return an error
2601        assert!(
2602            result.is_err(),
2603            "expected error when stream fails during append"
2604        );
2605
2606        // 2. File must contain initial content + partial data written before the error
2607        let content = tokio::fs::read_to_string(&target).await.unwrap();
2608        assert_eq!(
2609            content, "initial\npartial-",
2610            "append leaves partial data on stream error (non-atomic by nature)"
2611        );
2612    }
2613
2614    #[tokio::test]
2615    async fn test_producer_stream_already_consumed_errors() {
2616        let dir = tempfile::tempdir().unwrap();
2617        let dir_path = dir.path().to_str().unwrap();
2618        let uri = format!("file:{dir_path}?fileName=out.txt");
2619
2620        let component = FileComponent::new();
2621        let ctx = NoOpComponentContext;
2622        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2623        let producer = endpoint
2624            .create_producer(rt(), &test_producer_ctx())
2625            .unwrap();
2626
2627        // Mutex holds None -> stream already consumed
2628        type MaybeStream = std::sync::Arc<
2629            tokio::sync::Mutex<
2630                Option<
2631                    std::pin::Pin<
2632                        Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2633                    >,
2634                >,
2635            >,
2636        >;
2637        let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2638        let body = Body::Stream(StreamBody {
2639            stream: arc,
2640            metadata: StreamMetadata {
2641                size_hint: None,
2642                content_type: None,
2643                origin: None,
2644            },
2645        });
2646
2647        let exchange = Exchange::new(Message::new(body));
2648        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2649        assert!(
2650            result.is_err(),
2651            "expected error for already-consumed stream"
2652        );
2653    }
2654
2655    // -----------------------------------------------------------------------
2656    // GlobalConfig tests - apply_global_defaults behavior
2657    // -----------------------------------------------------------------------
2658
2659    #[test]
2660    fn test_global_config_applied_to_endpoint() {
2661        // Global config with non-default values
2662        let global = FileGlobalConfig::default()
2663            .with_delay_ms(2000)
2664            .with_initial_delay_ms(5000)
2665            .with_read_timeout_ms(60_000)
2666            .with_write_timeout_ms(45_000);
2667        let component = FileComponent::with_config(global);
2668        let ctx = NoOpComponentContext;
2669        // URI uses no explicit delay/timeout params → macro defaults apply
2670        let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2671        // We cannot call endpoint.config directly (FileEndpoint is private),
2672        // but we can test apply_global_defaults on FileConfig directly:
2673        let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2674        let global2 = FileGlobalConfig::default()
2675            .with_delay_ms(2000)
2676            .with_initial_delay_ms(5000)
2677            .with_read_timeout_ms(60_000)
2678            .with_write_timeout_ms(45_000);
2679        config.apply_global_defaults(&global2);
2680        assert_eq!(config.delay, Duration::from_millis(2000));
2681        assert_eq!(config.initial_delay, Duration::from_millis(5000));
2682        assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2683        assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2684        // endpoint creation succeeds too
2685        let _ = endpoint; // just verify create_endpoint didn't fail
2686    }
2687
2688    #[test]
2689    fn test_uri_param_wins_over_global_config() {
2690        // URI explicitly sets delay=1000 (NOT the 500ms macro default)
2691        let mut config =
2692            FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2693        // Global config would want 3000ms delay
2694        let global = FileGlobalConfig::default()
2695            .with_delay_ms(3000)
2696            .with_initial_delay_ms(4000);
2697        config.apply_global_defaults(&global);
2698        // URI value of 1000ms must be preserved (not replaced by 3000ms)
2699        assert_eq!(config.delay, Duration::from_millis(1000));
2700        // URI value of 2000ms must be preserved (not replaced by 4000ms)
2701        assert_eq!(config.initial_delay, Duration::from_millis(2000));
2702        // read_timeout was not set by URI → macro default (30000) → global wins if different
2703        // (read_timeout stays at 30000 since global has same default = 30000)
2704        assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2705    }
2706
2707    #[tokio::test]
2708    async fn test_file_producer_filename_simple_language_from_header() {
2709        use tower::ServiceExt;
2710
2711        let dir = tempfile::tempdir().unwrap();
2712        let dir_path = dir.path().to_str().unwrap();
2713
2714        let component = FileComponent::new();
2715        let ctx = NoOpComponentContext;
2716        let endpoint = component
2717            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2718            .unwrap();
2719        let ctx = test_producer_ctx();
2720        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2721
2722        let mut exchange = Exchange::new(Message::new("content"));
2723        exchange
2724            .input
2725            .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2726        exchange.input.set_header(
2727            "CamelFileName",
2728            serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2729        );
2730
2731        producer.oneshot(exchange).await.unwrap();
2732
2733        assert!(
2734            dir.path().join("test-42.txt").exists(),
2735            "fileName should have been evaluated from Simple Language expression"
2736        );
2737        let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2738        assert_eq!(content, "content");
2739    }
2740
2741    #[tokio::test]
2742    async fn test_file_producer_filename_simple_language_from_uri_param() {
2743        use tower::ServiceExt;
2744
2745        let dir = tempfile::tempdir().unwrap();
2746        let dir_path = dir.path().to_str().unwrap();
2747
2748        let component = FileComponent::new();
2749        let ctx = NoOpComponentContext;
2750        let endpoint = component
2751            .create_endpoint(
2752                &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2753                &ctx,
2754            )
2755            .unwrap();
2756        let ctx = test_producer_ctx();
2757        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2758
2759        let mut exchange = Exchange::new(Message::new("data"));
2760        exchange
2761            .input
2762            .set_header("id", serde_json::Value::String("abc".to_string()));
2763
2764        producer.oneshot(exchange).await.unwrap();
2765
2766        assert!(
2767            dir.path().join("msg-abc.dat").exists(),
2768            "fileName URI param should have been evaluated from Simple Language expression"
2769        );
2770    }
2771
2772    #[tokio::test]
2773    async fn test_file_producer_filename_literal_without_expression() {
2774        use tower::ServiceExt;
2775
2776        let dir = tempfile::tempdir().unwrap();
2777        let dir_path = dir.path().to_str().unwrap();
2778
2779        let component = FileComponent::new();
2780        let ctx = NoOpComponentContext;
2781        let endpoint = component
2782            .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2783            .unwrap();
2784        let ctx = test_producer_ctx();
2785        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2786
2787        let exchange = Exchange::new(Message::new("data"));
2788        producer.oneshot(exchange).await.unwrap();
2789
2790        assert!(
2791            dir.path().join("plain.txt").exists(),
2792            "literal fileName without expressions should still work"
2793        );
2794    }
2795
2796    // -----------------------------------------------------------------------
2797    // Config validation tests
2798    // -----------------------------------------------------------------------
2799
2800    #[test]
2801    fn test_rejects_path_traversal_in_move_to() {
2802        let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2803        assert!(result.is_err(), "should reject path traversal in move_to");
2804        let err = result.unwrap_err().to_string();
2805        assert!(
2806            err.contains("path traversal"),
2807            "error should mention path traversal: {err}"
2808        );
2809    }
2810
2811    #[test]
2812    fn test_rejects_absolute_move_to() {
2813        let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2814        assert!(result.is_err(), "should reject absolute move_to");
2815        let err = result.unwrap_err().to_string();
2816        assert!(
2817            err.contains("relative path") || err.contains("Invalid URI"),
2818            "error should mention invalid move_to path: {err}"
2819        );
2820    }
2821
2822    #[test]
2823    fn test_rejects_path_traversal_in_temp_prefix() {
2824        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2825        assert!(
2826            result.is_err(),
2827            "should reject path traversal in temp_prefix"
2828        );
2829        let err = result.unwrap_err().to_string();
2830        assert!(
2831            err.contains("path traversal"),
2832            "error should mention path traversal: {err}"
2833        );
2834    }
2835
2836    #[test]
2837    fn test_rejects_temp_prefix_with_path_separator() {
2838        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2839        assert!(result.is_err(), "should reject temp_prefix with separator");
2840        let err = result.unwrap_err().to_string();
2841        assert!(
2842            err.contains("plain filename prefix"),
2843            "error should mention plain filename prefix restriction: {err}"
2844        );
2845    }
2846
2847    #[test]
2848    fn test_rejects_absolute_temp_prefix() {
2849        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2850        assert!(result.is_err(), "should reject absolute temp_prefix");
2851        let err = result.unwrap_err().to_string();
2852        assert!(
2853            err.contains("plain filename prefix"),
2854            "error should mention plain filename prefix restriction: {err}"
2855        );
2856    }
2857
2858    #[test]
2859    fn test_rejects_null_byte_in_temp_prefix() {
2860        let config = FileConfig {
2861            directory: "/tmp/inbox".into(),
2862            delay: Duration::from_millis(500),
2863            delay_ms: 500,
2864            initial_delay: Duration::from_millis(1000),
2865            initial_delay_ms: 1000,
2866            noop: false,
2867            delete: false,
2868            move_to: None,
2869            file_name: Some("ok.txt".into()),
2870            include: None,
2871            exclude: None,
2872            ant_include: None,
2873            ant_exclude: None,
2874            include_ext: None,
2875            exclude_ext: None,
2876            recursive: false,
2877            file_exist: FileExistStrategy::Override,
2878            read_lock_strategy: ReadLockStrategy::None,
2879            idempotent_key: IdempotentKey::None,
2880            done_file_name: None,
2881            charset: None,
2882            temp_prefix: Some("tmp\0".into()),
2883            durable: false,
2884            auto_create: true,
2885            starting_directory_must_exist: false,
2886            read_timeout: Duration::from_millis(30_000),
2887            read_timeout_ms: 30_000,
2888            write_timeout: Duration::from_millis(30_000),
2889            write_timeout_ms: 30_000,
2890            max_depth: usize::MAX,
2891            min_depth: 0,
2892            max_messages_per_poll: 0,
2893            eager_max_messages_per_poll: true,
2894            shuffle: false,
2895            sort_spec: None,
2896            cleanup_stale_temps: true,
2897        };
2898
2899        let result = config.validate();
2900        assert!(result.is_err(), "should reject null byte in temp_prefix");
2901    }
2902
2903    #[test]
2904    fn test_rejects_null_byte_in_filename() {
2905        // Null bytes in URI params are typically URL-encoded, so we test via
2906        // direct config construction to simulate the validation logic.
2907        let config = FileConfig {
2908            directory: "/tmp/inbox".into(),
2909            delay: Duration::from_millis(500),
2910            delay_ms: 500,
2911            initial_delay: Duration::from_millis(1000),
2912            initial_delay_ms: 1000,
2913            noop: false,
2914            delete: false,
2915            move_to: None,
2916            file_name: Some("foo\0bar".into()),
2917            include: None,
2918            exclude: None,
2919            ant_include: None,
2920            ant_exclude: None,
2921            include_ext: None,
2922            exclude_ext: None,
2923            recursive: false,
2924            file_exist: FileExistStrategy::Override,
2925            read_lock_strategy: ReadLockStrategy::None,
2926            idempotent_key: IdempotentKey::None,
2927            done_file_name: None,
2928            charset: None,
2929            temp_prefix: None,
2930            durable: false,
2931            auto_create: true,
2932            starting_directory_must_exist: false,
2933            read_timeout: Duration::from_millis(30_000),
2934            read_timeout_ms: 30_000,
2935            write_timeout: Duration::from_millis(30_000),
2936            write_timeout_ms: 30_000,
2937            max_depth: usize::MAX,
2938            min_depth: 0,
2939            max_messages_per_poll: 0,
2940            eager_max_messages_per_poll: true,
2941            shuffle: false,
2942            sort_spec: None,
2943            cleanup_stale_temps: true,
2944        };
2945        let result = config.validate();
2946        assert!(result.is_err(), "should reject null byte in filename");
2947        let err = result.unwrap_err().to_string();
2948        assert!(
2949            err.contains("null"),
2950            "error should mention null bytes: {err}"
2951        );
2952    }
2953
2954    #[test]
2955    fn test_rejects_empty_filename() {
2956        let config = FileConfig {
2957            directory: "/tmp/inbox".into(),
2958            delay: Duration::from_millis(500),
2959            delay_ms: 500,
2960            initial_delay: Duration::from_millis(1000),
2961            initial_delay_ms: 1000,
2962            noop: false,
2963            delete: false,
2964            move_to: None,
2965            file_name: Some("".into()),
2966            include: None,
2967            exclude: None,
2968            ant_include: None,
2969            ant_exclude: None,
2970            include_ext: None,
2971            exclude_ext: None,
2972            recursive: false,
2973            file_exist: FileExistStrategy::Override,
2974            read_lock_strategy: ReadLockStrategy::None,
2975            idempotent_key: IdempotentKey::None,
2976            done_file_name: None,
2977            charset: None,
2978            temp_prefix: None,
2979            durable: false,
2980            auto_create: true,
2981            starting_directory_must_exist: false,
2982            read_timeout: Duration::from_millis(30_000),
2983            read_timeout_ms: 30_000,
2984            write_timeout: Duration::from_millis(30_000),
2985            write_timeout_ms: 30_000,
2986            max_depth: usize::MAX,
2987            min_depth: 0,
2988            max_messages_per_poll: 0,
2989            eager_max_messages_per_poll: true,
2990            shuffle: false,
2991            sort_spec: None,
2992            cleanup_stale_temps: true,
2993        };
2994        let result = config.validate();
2995        assert!(result.is_err(), "should reject empty filename");
2996        let err = result.unwrap_err().to_string();
2997        assert!(
2998            err.contains("empty") || err.contains("must not"),
2999            "error should mention empty: {err}"
3000        );
3001    }
3002
3003    #[test]
3004    fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
3005        let result =
3006            FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
3007        assert!(
3008            result.is_err(),
3009            "should reject non-existent directory when startingDirectoryMustExist=true"
3010        );
3011        let err = result.unwrap_err().to_string();
3012        assert!(
3013            err.contains("does not exist"),
3014            "error should mention directory does not exist: {err}"
3015        );
3016    }
3017
3018    #[test]
3019    fn test_accepts_existing_directory_when_starting_directory_must_exist() {
3020        let dir = tempfile::tempdir().unwrap();
3021        let dir_path = dir.path().to_str().unwrap();
3022        let result =
3023            FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
3024        assert!(
3025            result.is_ok(),
3026            "should accept existing directory when startingDirectoryMustExist=true: {:?}",
3027            result.err()
3028        );
3029    }
3030
3031    #[test]
3032    fn test_valid_config_passes() {
3033        let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
3034        assert!(cfg.validate().is_ok());
3035    }
3036
3037    #[test]
3038    fn test_file_exist_strategy_rejects_unknown() {
3039        // Unknown strategy values should be rejected by from_str
3040        let result = FileExistStrategy::from_str("BogusValue");
3041        assert!(
3042            result.is_err(),
3043            "unknown FileExistStrategy should return Err"
3044        );
3045    }
3046
3047    #[test]
3048    fn test_path_contains_traversal_detects_parent_dir() {
3049        assert!(path_contains_traversal("../etc/passwd"));
3050        assert!(path_contains_traversal("foo/../bar"));
3051        assert!(path_contains_traversal(".."));
3052        assert!(path_contains_traversal("a/b/../../../c"));
3053    }
3054
3055    #[test]
3056    fn test_path_contains_traversal_accepts_safe_paths() {
3057        assert!(!path_contains_traversal("safe/path"));
3058        assert!(!path_contains_traversal("/absolute/path"));
3059        assert!(!path_contains_traversal("filename.txt"));
3060        assert!(!path_contains_traversal(".hidden"));
3061        assert!(!path_contains_traversal(""));
3062    }
3063
3064    // -----------------------------------------------------------------------
3065    // File modification detection tests (C-20)
3066    // -----------------------------------------------------------------------
3067
3068    #[tokio::test]
3069    async fn test_stream_detects_file_modified_during_read() {
3070        use futures::StreamExt;
3071
3072        let dir = tempfile::tempdir().unwrap();
3073        let file_path = dir.path().join("mutable.txt");
3074        std::fs::write(&file_path, b"initial content here").unwrap();
3075
3076        // Capture initial metadata
3077        let initial_meta = std::fs::metadata(&file_path).unwrap();
3078        let initial_size = initial_meta.len();
3079        let initial_mtime = initial_meta.modified().ok();
3080
3081        // Create a raw file stream (simulating what the consumer does)
3082        let file = tokio::fs::File::open(&file_path).await.unwrap();
3083        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3084
3085        let mut stream = ModificationDetectingStream::new(
3086            raw_stream,
3087            file_path.clone(),
3088            initial_size,
3089            initial_mtime,
3090        );
3091
3092        // Read a chunk (file not modified yet — should succeed)
3093        let chunk = stream.next().await;
3094        assert!(chunk.is_some(), "should produce at least one chunk");
3095        assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
3096
3097        // Modify the file (change content/size)
3098        std::fs::write(&file_path, b"modified content - different size!!").unwrap();
3099
3100        // Drain remaining chunks — the stream should produce an error after EOF
3101        let mut got_error = false;
3102        while let Some(result) = stream.next().await {
3103            if let Err(err) = result {
3104                let msg = err.to_string();
3105                assert!(
3106                    msg.contains("file modified during read"),
3107                    "expected modification error, got: {msg}"
3108                );
3109                got_error = true;
3110                break;
3111            }
3112        }
3113
3114        assert!(
3115            got_error,
3116            "stream should detect file was modified during read"
3117        );
3118    }
3119
3120    #[tokio::test]
3121    async fn test_stream_succeeds_when_file_not_modified() {
3122        use futures::StreamExt;
3123
3124        let dir = tempfile::tempdir().unwrap();
3125        let file_path = dir.path().join("stable.txt");
3126        std::fs::write(&file_path, b"stable content").unwrap();
3127
3128        let initial_meta = std::fs::metadata(&file_path).unwrap();
3129        let initial_size = initial_meta.len();
3130        let initial_mtime = initial_meta.modified().ok();
3131
3132        let file = tokio::fs::File::open(&file_path).await.unwrap();
3133        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3134
3135        let mut stream = ModificationDetectingStream::new(
3136            raw_stream,
3137            file_path.clone(),
3138            initial_size,
3139            initial_mtime,
3140        );
3141
3142        // Drain entire stream — should produce no errors
3143        let mut all_ok = true;
3144        while let Some(result) = stream.next().await {
3145            if result.is_err() {
3146                all_ok = false;
3147                break;
3148            }
3149        }
3150
3151        assert!(
3152            all_ok,
3153            "stream should complete without errors when file is not modified"
3154        );
3155    }
3156
3157    // -----------------------------------------------------------------------
3158    // SortSpec::from_str tests
3159    // -----------------------------------------------------------------------
3160
3161    #[test]
3162    fn test_sort_spec_bare_field() {
3163        let spec: SortSpec = "file:name".parse().unwrap();
3164        assert_eq!(spec.groups.len(), 1);
3165        let g = &spec.groups[0];
3166        assert_eq!(g.field, SortField::Name);
3167        assert!(!g.reverse);
3168        assert!(!g.ignore_case);
3169    }
3170
3171    #[test]
3172    fn test_sort_spec_reverse() {
3173        let spec: SortSpec = "reverse:file:length".parse().unwrap();
3174        assert_eq!(spec.groups.len(), 1);
3175        let g = &spec.groups[0];
3176        assert_eq!(g.field, SortField::Length);
3177        assert!(g.reverse);
3178        assert!(!g.ignore_case);
3179    }
3180
3181    #[test]
3182    fn test_sort_spec_ignore_case() {
3183        let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
3184        assert_eq!(spec.groups.len(), 1);
3185        let g = &spec.groups[0];
3186        assert_eq!(g.field, SortField::Name);
3187        assert!(!g.reverse);
3188        assert!(g.ignore_case);
3189    }
3190
3191    #[test]
3192    fn test_sort_spec_reverse_ignore_case() {
3193        let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
3194        assert_eq!(spec.groups.len(), 1);
3195        let g = &spec.groups[0];
3196        assert_eq!(g.field, SortField::Modified);
3197        assert!(g.reverse);
3198        assert!(g.ignore_case);
3199    }
3200
3201    #[test]
3202    fn test_sort_spec_wrong_order_rejection() {
3203        let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
3204        assert!(result.is_err());
3205        let err = result.unwrap_err();
3206        assert!(
3207            err.contains("reverse must precede"),
3208            "expected 'reverse must precede' error, got: {err}"
3209        );
3210    }
3211
3212    #[test]
3213    fn test_sort_spec_multi_group() {
3214        let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
3215        assert_eq!(spec.groups.len(), 2);
3216        assert_eq!(spec.groups[0].field, SortField::Name);
3217        assert!(!spec.groups[0].reverse);
3218        assert_eq!(spec.groups[1].field, SortField::Length);
3219        assert!(spec.groups[1].reverse);
3220    }
3221
3222    #[test]
3223    fn test_sort_spec_unsupported_field() {
3224        let result: Result<SortSpec, _> = "file:unknown".parse();
3225        assert!(result.is_err());
3226        let err = result.unwrap_err();
3227        assert!(
3228            err.contains("unsupported"),
3229            "expected 'unsupported' error, got: {err}"
3230        );
3231    }
3232
3233    #[test]
3234    fn test_sort_spec_empty_string() {
3235        let result: Result<SortSpec, _> = "".parse();
3236        assert!(result.is_err());
3237        let err = result.unwrap_err();
3238        assert!(
3239            err.contains("at least one group"),
3240            "expected 'at least one group' error, got: {err}"
3241        );
3242    }
3243
3244    #[tokio::test]
3245    async fn test_max_depth_limits_recursion() {
3246        let dir = tempfile::tempdir().unwrap();
3247        let base = dir.path();
3248        fs::create_dir_all(base.join("sub/deep")).await.unwrap();
3249        fs::write(base.join("root.txt"), b"r").await.unwrap();
3250        fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
3251        fs::write(base.join("sub/deep/leaf.txt"), b"l")
3252            .await
3253            .unwrap();
3254
3255        let config = FileConfig::from_uri(&format!(
3256            "file://{}?recursive=true&maxDepth=2",
3257            base.display()
3258        ))
3259        .unwrap();
3260        let filters = CompiledFilters::compile(&config).unwrap();
3261        let mut seen = HashSet::new();
3262        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3263            .await
3264            .unwrap()
3265            .candidates;
3266        let names: Vec<String> = candidates
3267            .iter()
3268            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3269            .collect();
3270        assert!(names.contains(&"root.txt".to_string()));
3271        assert!(names.contains(&"mid.txt".to_string()));
3272        assert!(!names.contains(&"leaf.txt".to_string()));
3273    }
3274
3275    #[tokio::test]
3276    async fn test_min_depth_skips_base_files() {
3277        let dir = tempfile::tempdir().unwrap();
3278        let base = dir.path();
3279        fs::create_dir_all(base.join("sub")).await.unwrap();
3280        fs::write(base.join("root.txt"), b"r").await.unwrap();
3281        fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3282
3283        let config = FileConfig::from_uri(&format!(
3284            "file://{}?recursive=true&minDepth=2",
3285            base.display()
3286        ))
3287        .unwrap();
3288        let filters = CompiledFilters::compile(&config).unwrap();
3289        let mut seen = HashSet::new();
3290        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3291            .await
3292            .unwrap()
3293            .candidates;
3294        let names: Vec<String> = candidates
3295            .iter()
3296            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3297            .collect();
3298        assert!(!names.contains(&"root.txt".to_string()));
3299        assert!(names.contains(&"deep.txt".to_string()));
3300    }
3301
3302    #[tokio::test]
3303    async fn test_include_ext_filter() {
3304        let dir = tempfile::tempdir().unwrap();
3305        let base = dir.path();
3306        fs::write(base.join("data.txt"), b"t").await.unwrap();
3307        fs::write(base.join("data.csv"), b"c").await.unwrap();
3308        fs::write(base.join("data.json"), b"j").await.unwrap();
3309
3310        let config =
3311            FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3312        let filters = CompiledFilters::compile(&config).unwrap();
3313        let mut seen = HashSet::new();
3314        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3315            .await
3316            .unwrap()
3317            .candidates;
3318        let names: Vec<String> = candidates
3319            .iter()
3320            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3321            .collect();
3322        assert_eq!(names.len(), 2);
3323        assert!(names.contains(&"data.txt".to_string()));
3324        assert!(names.contains(&"data.csv".to_string()));
3325    }
3326
3327    #[tokio::test]
3328    async fn test_ant_include_glob() {
3329        let dir = tempfile::tempdir().unwrap();
3330        let base = dir.path();
3331        fs::write(base.join("report.txt"), b"r").await.unwrap();
3332        fs::write(base.join("data.csv"), b"d").await.unwrap();
3333        fs::write(base.join("image.png"), b"i").await.unwrap();
3334
3335        let config =
3336            FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3337                .unwrap();
3338        let filters = CompiledFilters::compile(&config).unwrap();
3339        let mut seen = HashSet::new();
3340        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3341            .await
3342            .unwrap()
3343            .candidates;
3344        let names: Vec<String> = candidates
3345            .iter()
3346            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3347            .collect();
3348        assert_eq!(names.len(), 2);
3349        assert!(names.contains(&"report.txt".to_string()));
3350        assert!(names.contains(&"data.csv".to_string()));
3351    }
3352
3353    #[tokio::test]
3354    async fn test_exclude_ext_filter() {
3355        let dir = tempfile::tempdir().unwrap();
3356        let base = dir.path();
3357        fs::write(base.join("keep.txt"), b"k").await.unwrap();
3358        fs::write(base.join("skip.log"), b"s").await.unwrap();
3359
3360        let config =
3361            FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3362        let filters = CompiledFilters::compile(&config).unwrap();
3363        let mut seen = HashSet::new();
3364        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3365            .await
3366            .unwrap()
3367            .candidates;
3368        let names: Vec<String> = candidates
3369            .iter()
3370            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3371            .collect();
3372        assert_eq!(names.len(), 1);
3373        assert!(names.contains(&"keep.txt".to_string()));
3374    }
3375
3376    #[tokio::test]
3377    async fn test_ant_exclude_glob() {
3378        let dir = tempfile::tempdir().unwrap();
3379        let base = dir.path();
3380        fs::write(base.join("report.txt"), b"r").await.unwrap();
3381        fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3382
3383        let config =
3384            FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3385        let filters = CompiledFilters::compile(&config).unwrap();
3386        let mut seen = HashSet::new();
3387        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3388            .await
3389            .unwrap()
3390            .candidates;
3391        let names: Vec<String> = candidates
3392            .iter()
3393            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3394            .collect();
3395        assert_eq!(names.len(), 1);
3396        assert!(names.contains(&"report.txt".to_string()));
3397    }
3398
3399    #[tokio::test]
3400    async fn test_done_file_consumer_skips_without_marker() {
3401        let dir = tempfile::tempdir().unwrap();
3402        let base = dir.path();
3403        fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3404        fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3405        fs::write(base.join("pending.txt"), b"pending")
3406            .await
3407            .unwrap();
3408
3409        let config = FileConfig::from_uri(&format!(
3410            "file://{}?doneFileName=${{file:name}}.done",
3411            base.display()
3412        ))
3413        .unwrap();
3414        let filters = CompiledFilters::compile(&config).unwrap();
3415        let mut seen = HashSet::new();
3416        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3417            .await
3418            .unwrap()
3419            .candidates;
3420        let names: Vec<String> = candidates
3421            .iter()
3422            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3423            .collect();
3424        assert!(names.contains(&"ready.txt".to_string()));
3425        assert!(!names.contains(&"pending.txt".to_string()));
3426        assert!(!names.contains(&"ready.txt.done".to_string()));
3427    }
3428
3429    #[tokio::test]
3430    async fn test_done_file_static_pattern() {
3431        let dir = tempfile::tempdir().unwrap();
3432        let base = dir.path();
3433        fs::write(base.join("data.txt"), b"d").await.unwrap();
3434        fs::write(base.join("ready"), b"").await.unwrap();
3435
3436        let config =
3437            FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3438        let filters = CompiledFilters::compile(&config).unwrap();
3439        let mut seen = HashSet::new();
3440        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3441            .await
3442            .unwrap()
3443            .candidates;
3444        assert_eq!(candidates.len(), 1);
3445        assert_eq!(
3446            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3447            "data.txt"
3448        );
3449    }
3450
3451    // -----------------------------------------------------------------------
3452    // Sort + shuffle tests
3453    // -----------------------------------------------------------------------
3454
3455    #[tokio::test]
3456    async fn test_sort_by_name() {
3457        let dir = tempfile::tempdir().unwrap();
3458        let base = dir.path();
3459        fs::write(base.join("c.txt"), b"3").await.unwrap();
3460        fs::write(base.join("a.txt"), b"1").await.unwrap();
3461        fs::write(base.join("b.txt"), b"2").await.unwrap();
3462
3463        let config =
3464            FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3465        let filters = CompiledFilters::compile(&config).unwrap();
3466        let mut seen = HashSet::new();
3467        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3468            .await
3469            .unwrap()
3470            .candidates;
3471        apply_sort_and_limit(&mut candidates, &config);
3472        let names: Vec<&str> = candidates
3473            .iter()
3474            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3475            .collect();
3476        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3477    }
3478
3479    #[tokio::test]
3480    async fn test_sort_by_length() {
3481        let dir = tempfile::tempdir().unwrap();
3482        let base = dir.path();
3483        fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3484        fs::write(base.join("small.txt"), b"x").await.unwrap();
3485
3486        let config =
3487            FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3488        let filters = CompiledFilters::compile(&config).unwrap();
3489        let mut seen = HashSet::new();
3490        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3491            .await
3492            .unwrap()
3493            .candidates;
3494        apply_sort_and_limit(&mut candidates, &config);
3495        let names: Vec<&str> = candidates
3496            .iter()
3497            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3498            .collect();
3499        assert_eq!(names, vec!["small.txt", "big.txt"]);
3500    }
3501
3502    #[tokio::test]
3503    async fn test_sort_by_reverse_name() {
3504        let dir = tempfile::tempdir().unwrap();
3505        let base = dir.path();
3506        fs::write(base.join("a.txt"), b"1").await.unwrap();
3507        fs::write(base.join("b.txt"), b"2").await.unwrap();
3508        fs::write(base.join("c.txt"), b"3").await.unwrap();
3509
3510        let config = FileConfig::from_uri(&format!(
3511            "file://{}?sortBy=reverse:file:name",
3512            base.display()
3513        ))
3514        .unwrap();
3515        let filters = CompiledFilters::compile(&config).unwrap();
3516        let mut seen = HashSet::new();
3517        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3518            .await
3519            .unwrap()
3520            .candidates;
3521        apply_sort_and_limit(&mut candidates, &config);
3522        let names: Vec<&str> = candidates
3523            .iter()
3524            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3525            .collect();
3526        assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3527    }
3528
3529    #[tokio::test]
3530    async fn test_shuffle_deterministic() {
3531        let dir = tempfile::tempdir().unwrap();
3532        let base = dir.path();
3533        fs::write(base.join("a.txt"), b"1").await.unwrap();
3534        fs::write(base.join("b.txt"), b"2").await.unwrap();
3535        fs::write(base.join("c.txt"), b"3").await.unwrap();
3536        fs::write(base.join("d.txt"), b"4").await.unwrap();
3537
3538        let config =
3539            FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3540        let filters = CompiledFilters::compile(&config).unwrap();
3541        let mut seen = HashSet::new();
3542        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3543            .await
3544            .unwrap()
3545            .candidates;
3546        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3547
3548        let mut candidates2 =
3549            crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3550                .await
3551                .unwrap()
3552                .candidates;
3553        crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3554
3555        let names1: Vec<&str> = candidates
3556            .iter()
3557            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3558            .collect();
3559        let names2: Vec<&str> = candidates2
3560            .iter()
3561            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3562            .collect();
3563        assert_eq!(names1, names2);
3564        assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3565    }
3566
3567    #[tokio::test]
3568    async fn test_non_eager_limit() {
3569        let dir = tempfile::tempdir().unwrap();
3570        let base = dir.path();
3571        fs::write(base.join("a.txt"), b"1").await.unwrap();
3572        fs::write(base.join("b.txt"), b"2").await.unwrap();
3573        fs::write(base.join("c.txt"), b"3").await.unwrap();
3574
3575        let config = FileConfig::from_uri(&format!(
3576            "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3577            base.display()
3578        ))
3579        .unwrap();
3580        let filters = CompiledFilters::compile(&config).unwrap();
3581        let mut seen = HashSet::new();
3582        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3583            .await
3584            .unwrap()
3585            .candidates;
3586        assert_eq!(candidates.len(), 3);
3587        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3588        assert_eq!(candidates.len(), 2);
3589    }
3590
3591    #[tokio::test]
3592    async fn test_poll_one_file_sets_all_headers() {
3593        let dir = tempfile::tempdir().unwrap();
3594        let base = dir.path();
3595        fs::write(base.join("test.txt"), b"hello world")
3596            .await
3597            .unwrap();
3598
3599        let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3600
3601        let file_path = base.join("test.txt");
3602        let include_re = None;
3603        let exclude_re = None;
3604        let mut seen = std::collections::HashSet::new();
3605        let in_process_locks = std::sync::Arc::new(DashMap::new());
3606        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3607
3608        let result = poll_one_file(
3609            &config,
3610            file_path.clone(),
3611            base,
3612            &include_re,
3613            &exclude_re,
3614            &mut seen,
3615            &in_process_locks,
3616            &idempotent_repo,
3617        )
3618        .await
3619        .unwrap();
3620
3621        let exchange = result.expect("poll_one_file should return an exchange");
3622
3623        let camel_file_name = exchange
3624            .input
3625            .header("CamelFileName")
3626            .and_then(|v| v.as_str().map(String::from))
3627            .expect("CamelFileName header");
3628        let camel_file_name_only = exchange
3629            .input
3630            .header("CamelFileNameOnly")
3631            .and_then(|v| v.as_str().map(String::from))
3632            .expect("CamelFileNameOnly header");
3633        let camel_file_absolute_path = exchange
3634            .input
3635            .header("CamelFileAbsolutePath")
3636            .and_then(|v| v.as_str().map(String::from))
3637            .expect("CamelFileAbsolutePath header");
3638        exchange
3639            .input
3640            .header("CamelFileLength")
3641            .and_then(|v| v.as_u64())
3642            .expect("CamelFileLength header");
3643        exchange
3644            .input
3645            .header("CamelFileLastModified")
3646            .and_then(|v| v.as_u64())
3647            .expect("CamelFileLastModified header");
3648        let camel_file_path = exchange
3649            .input
3650            .header("CamelFilePath")
3651            .and_then(|v| v.as_str().map(String::from))
3652            .expect("CamelFilePath header");
3653        let camel_file_parent = exchange
3654            .input
3655            .header("CamelFileParent")
3656            .and_then(|v| v.as_str().map(String::from))
3657            .expect("CamelFileParent header");
3658        let camel_file_canonical_path = exchange
3659            .input
3660            .header("CamelFileCanonicalPath")
3661            .and_then(|v| v.as_str().map(String::from))
3662            .expect("CamelFileCanonicalPath header");
3663        let camel_file_relative_path = exchange
3664            .input
3665            .header("CamelFileRelativePath")
3666            .and_then(|v| v.as_str().map(String::from))
3667            .expect("CamelFileRelativePath header");
3668
3669        assert_eq!(camel_file_name, "test.txt");
3670        assert_eq!(camel_file_name_only, "test.txt");
3671        assert_eq!(camel_file_relative_path, camel_file_name);
3672        assert_eq!(camel_file_path, base.to_string_lossy());
3673        assert_eq!(camel_file_parent, base.to_string_lossy());
3674        assert!(camel_file_absolute_path.ends_with("test.txt"));
3675        assert!(camel_file_canonical_path.ends_with("test.txt"));
3676    }
3677
3678    #[test]
3679    fn test_try_rename_requires_temp_prefix() {
3680        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3681        assert!(result.is_err());
3682        assert!(
3683            result
3684                .unwrap_err()
3685                .to_string()
3686                .contains("TryRename requires tempPrefix")
3687        );
3688    }
3689
3690    #[test]
3691    fn test_try_rename_with_temp_prefix_ok() {
3692        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3693        assert!(result.is_ok());
3694        assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3695    }
3696
3697    #[tokio::test]
3698    async fn test_try_rename_producer_writes_file() {
3699        use tower::ServiceExt;
3700
3701        let dir = tempfile::tempdir().unwrap();
3702        let base = dir.path();
3703
3704        let component = FileComponent::new();
3705        let ctx = NoOpComponentContext;
3706        let endpoint = component
3707            .create_endpoint(
3708                &format!(
3709                    "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3710                    base.display()
3711                ),
3712                &ctx,
3713            )
3714            .unwrap();
3715        let ctx = test_producer_ctx();
3716        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3717
3718        let mut exchange = Exchange::new(Message::new("hello"));
3719        exchange.input.set_header(
3720            "CamelFileName",
3721            serde_json::Value::String("output.txt".to_string()),
3722        );
3723
3724        producer.oneshot(exchange).await.unwrap();
3725
3726        let target = base.join("output.txt");
3727        assert!(target.exists(), "output file should exist");
3728        let content = std::fs::read_to_string(&target).unwrap();
3729        assert_eq!(content, "hello");
3730
3731        let tmp_files: Vec<_> = std::fs::read_dir(base)
3732            .unwrap()
3733            .filter_map(|e| e.ok())
3734            .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3735            .collect();
3736        assert!(tmp_files.is_empty(), "no temp files should remain");
3737    }
3738
3739    // -----------------------------------------------------------------------
3740    // Integration tests: scan_candidates + apply_sort_and_limit pipeline
3741    // -----------------------------------------------------------------------
3742
3743    #[tokio::test]
3744    async fn test_pipeline_sort_then_limit() {
3745        let dir = tempfile::tempdir().unwrap();
3746        let base = dir.path();
3747        fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3748        fs::write(base.join("a.txt"), b"a").await.unwrap();
3749        fs::write(base.join("b.txt"), b"bb").await.unwrap();
3750
3751        let config = FileConfig::from_uri(&format!(
3752            "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3753            base.display()
3754        ))
3755        .unwrap();
3756        let filters = CompiledFilters::compile(&config).unwrap();
3757        let mut seen = HashSet::new();
3758        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3759            .await
3760            .unwrap()
3761            .candidates;
3762        apply_sort_and_limit(&mut candidates, &config);
3763        assert_eq!(candidates.len(), 2);
3764        assert_eq!(
3765            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3766            "a.txt"
3767        );
3768        assert_eq!(
3769            candidates[1].path.file_name().unwrap().to_str().unwrap(),
3770            "b.txt"
3771        );
3772    }
3773
3774    #[tokio::test]
3775    async fn test_sort_reverse_ignore_case() {
3776        let dir = tempfile::tempdir().unwrap();
3777        let base = dir.path();
3778        fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3779        fs::write(base.join("beta.txt"), b"b").await.unwrap();
3780
3781        let config = FileConfig::from_uri(&format!(
3782            "file://{}?sortBy=reverse:ignoreCase:file:name",
3783            base.display()
3784        ))
3785        .unwrap();
3786        let filters = CompiledFilters::compile(&config).unwrap();
3787        let mut seen = HashSet::new();
3788        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3789            .await
3790            .unwrap()
3791            .candidates;
3792        apply_sort_and_limit(&mut candidates, &config);
3793        assert_eq!(
3794            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3795            "beta.txt"
3796        );
3797    }
3798
3799    #[tokio::test]
3800    async fn test_done_file_noop_no_deletion() {
3801        let dir = tempfile::tempdir().unwrap();
3802        let base = dir.path();
3803        fs::write(base.join("data.txt"), b"d").await.unwrap();
3804        fs::write(base.join("done"), b"").await.unwrap();
3805
3806        let config = FileConfig::from_uri(&format!(
3807            "file://{}?doneFileName=done&noop=true",
3808            base.display()
3809        ))
3810        .unwrap();
3811        let filters = CompiledFilters::compile(&config).unwrap();
3812        let mut seen = HashSet::new();
3813        let candidates = scan_candidates(&config, &filters, base, &mut seen)
3814            .await
3815            .unwrap()
3816            .candidates;
3817        assert_eq!(candidates.len(), 1);
3818        assert!(base.join("done").exists());
3819    }
3820
3821    #[tokio::test]
3822    async fn test_static_done_file_deleted_after_all_processed() {
3823        let dir = tempfile::tempdir().unwrap();
3824        let base = dir.path();
3825        fs::write(base.join("data.txt"), b"d").await.unwrap();
3826        fs::write(base.join("ready"), b"").await.unwrap();
3827
3828        let config = FileConfig::from_uri(&format!(
3829            "file://{}?doneFileName=ready&delete=true",
3830            base.display()
3831        ))
3832        .unwrap();
3833        let filters = CompiledFilters::compile(&config).unwrap();
3834        let mut seen = HashSet::new();
3835        let in_process_locks = std::sync::Arc::new(DashMap::new());
3836        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3837        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3838        let token = CancellationToken::new();
3839        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3840
3841        poll_directory(
3842            &config,
3843            &ctx,
3844            &rt(),
3845            &filters,
3846            &mut seen,
3847            &in_process_locks,
3848            &idempotent_repo,
3849        )
3850        .await
3851        .unwrap();
3852
3853        assert!(rx.try_recv().is_ok());
3854        assert!(!base.join("ready").exists());
3855    }
3856
3857    #[tokio::test]
3858    async fn test_static_done_file_not_deleted_when_limited() {
3859        let dir = tempfile::tempdir().unwrap();
3860        let base = dir.path();
3861        fs::write(base.join("a.txt"), b"a").await.unwrap();
3862        fs::write(base.join("b.txt"), b"b").await.unwrap();
3863        fs::write(base.join("ready"), b"").await.unwrap();
3864
3865        let config = FileConfig::from_uri(&format!(
3866            "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
3867            base.display()
3868        ))
3869        .unwrap();
3870        let filters = CompiledFilters::compile(&config).unwrap();
3871        let mut seen = HashSet::new();
3872        let in_process_locks = std::sync::Arc::new(DashMap::new());
3873        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3874        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3875        let token = CancellationToken::new();
3876        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3877
3878        poll_directory(
3879            &config,
3880            &ctx,
3881            &rt(),
3882            &filters,
3883            &mut seen,
3884            &in_process_locks,
3885            &idempotent_repo,
3886        )
3887        .await
3888        .unwrap();
3889
3890        let _ = rx.try_recv();
3891        assert!(base.join("ready").exists());
3892    }
3893
3894    #[tokio::test]
3895    async fn test_dynamic_done_file_deleted_per_file() {
3896        let dir = tempfile::tempdir().unwrap();
3897        let base = dir.path();
3898        fs::write(base.join("data.txt"), b"d").await.unwrap();
3899        fs::write(base.join("data.txt.done"), b"").await.unwrap();
3900
3901        let config = FileConfig::from_uri(&format!(
3902            "file://{}?doneFileName=${{file:name}}.done&delete=true",
3903            base.display()
3904        ))
3905        .unwrap();
3906        let filters = CompiledFilters::compile(&config).unwrap();
3907        let mut seen = HashSet::new();
3908        let in_process_locks = std::sync::Arc::new(DashMap::new());
3909        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3910        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3911        let token = CancellationToken::new();
3912        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3913
3914        poll_directory(
3915            &config,
3916            &ctx,
3917            &rt(),
3918            &filters,
3919            &mut seen,
3920            &in_process_locks,
3921            &idempotent_repo,
3922        )
3923        .await
3924        .unwrap();
3925
3926        assert!(rx.try_recv().is_ok());
3927        assert!(!base.join("data.txt.done").exists());
3928    }
3929
3930    #[test]
3931    fn uri_options_count_parity() {
3932        assert_eq!(
3933            FileConfig::uri_options().len(),
3934            31,
3935            "FileUriConfig #[uri_param] count drifted from parser"
3936        );
3937    }
3938
3939    // -------------------------------------------------------------------------
3940    // Recording metrics collector for testing increment_errors calls
3941    // (pattern: camel-direct tests::RecordingMetrics)
3942    // -------------------------------------------------------------------------
3943
3944    struct RecordingMetrics {
3945        errors: Arc<std::sync::Mutex<Vec<(String, String)>>>,
3946    }
3947
3948    impl camel_api::MetricsCollector for RecordingMetrics {
3949        fn record_exchange_duration(&self, _: &str, _: Duration) {}
3950        fn increment_errors(&self, route_id: &str, error_type: &str) {
3951            self.errors
3952                .lock()
3953                .unwrap()
3954                .push((route_id.to_string(), error_type.to_string()));
3955        }
3956        fn increment_exchanges(&self, _: &str) {}
3957        fn set_queue_depth(&self, _: &str, _: usize) {}
3958        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
3959    }
3960
3961    struct RecordingRuntime {
3962        metrics_collector: Arc<RecordingMetrics>,
3963    }
3964
3965    impl RecordingRuntime {
3966        fn new(errors: Arc<std::sync::Mutex<Vec<(String, String)>>>) -> Self {
3967            Self {
3968                metrics_collector: Arc::new(RecordingMetrics { errors }),
3969            }
3970        }
3971    }
3972
3973    impl camel_component_api::RuntimeObservability for RecordingRuntime {
3974        fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
3975            self.metrics_collector.clone() as Arc<dyn camel_api::MetricsCollector>
3976        }
3977        fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
3978            panic!("RecordingRuntime::health not used in this test")
3979        }
3980    }
3981
3982    #[tokio::test]
3983    async fn file_poll_send_failure_counts_b_prime() {
3984        // Positive: a poll whose pipeline send fails (receiver dropped) counts
3985        // exactly one `b-prime:file:poll-send` emission and propagates
3986        // ChannelClosed.
3987        let dir = tempfile::tempdir().unwrap();
3988        let base = dir.path();
3989        std::fs::write(base.join("data.txt"), b"payload").unwrap();
3990
3991        let config = FileConfig::from_uri(&format!("file:{}", base.display())).unwrap();
3992        let filters = CompiledFilters::compile(&config).unwrap();
3993        let mut seen = HashSet::new();
3994        let in_process_locks = std::sync::Arc::new(DashMap::new());
3995        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3996
3997        let (tx, rx) = tokio::sync::mpsc::channel(16);
3998        drop(rx); // pipeline receiver gone → context.send fails
3999        let token = CancellationToken::new();
4000        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4001
4002        let errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4003        let runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4004            Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
4005
4006        let result = poll_directory(
4007            &config,
4008            &ctx,
4009            &runtime,
4010            &filters,
4011            &mut seen,
4012            &in_process_locks,
4013            &idempotent_repo,
4014        )
4015        .await;
4016
4017        assert!(matches!(result, Err(CamelError::ChannelClosed)));
4018        let recorded = errors.lock().unwrap().clone();
4019        assert_eq!(
4020            recorded,
4021            vec![(
4022                "file-test-route".to_string(),
4023                "b-prime:file:poll-send".to_string()
4024            )]
4025        );
4026
4027        // Negative: a scan failure (directory gone) must NOT emit the
4028        // poll-send label — the failure is not a dispatch send.
4029        let gone = tempfile::tempdir().unwrap();
4030        let gone_path = gone.path().to_path_buf();
4031        let scan_config = FileConfig::from_uri(&format!("file:{}", gone_path.display())).unwrap();
4032        let scan_filters = CompiledFilters::compile(&scan_config).unwrap();
4033        drop(gone); // directory no longer exists → scan_candidates fails
4034
4035        let mut scan_seen = HashSet::new();
4036        let scan_errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4037        let scan_runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4038            Arc::new(RecordingRuntime::new(Arc::clone(&scan_errors)));
4039        let (scan_tx, mut scan_rx) = tokio::sync::mpsc::channel(16);
4040        let scan_token = CancellationToken::new();
4041        let scan_ctx = ConsumerContext::new(scan_tx, scan_token, "file-test-route".to_string());
4042
4043        let scan_result = poll_directory(
4044            &scan_config,
4045            &scan_ctx,
4046            &scan_runtime,
4047            &scan_filters,
4048            &mut scan_seen,
4049            &in_process_locks,
4050            &idempotent_repo,
4051        )
4052        .await;
4053
4054        assert!(scan_result.is_err());
4055        assert!(
4056            scan_errors.lock().unwrap().is_empty(),
4057            "scan failure must not emit b-prime:file:poll-send"
4058        );
4059        assert!(scan_rx.try_recv().is_err());
4060    }
4061}