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    #[allow(dead_code)]
1033    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1034}
1035
1036impl FileConsumer {
1037    fn new(
1038        config: FileConfig,
1039        filters: CompiledFilters,
1040        in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
1041        idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
1042        runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1043    ) -> Self {
1044        Self {
1045            config,
1046            filters,
1047            seen: HashSet::new(),
1048            in_process_locks,
1049            idempotent_repo,
1050            runtime,
1051        }
1052    }
1053}
1054
1055#[async_trait]
1056impl Consumer for FileConsumer {
1057    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1058        let config = self.config.clone();
1059
1060        // Sweep stale temp files left by a previous crash/SIGKILL.
1061        if config.cleanup_stale_temps {
1062            let stale_age =
1063                Duration::from_secs((config.write_timeout.as_secs()).saturating_mul(4).max(60));
1064            let prefix = config
1065                .temp_prefix
1066                .as_deref()
1067                .unwrap_or(atomic_write::DEFAULT_TEMP_PREFIX);
1068            let dir = std::path::Path::new(&config.directory);
1069            if let Err(e) = atomic_write::sweep_stale_temps(dir, prefix, stale_age).await {
1070                warn!(
1071                    directory = config.directory,
1072                    error = %e,
1073                    "Stale temp sweep failed at startup"
1074                );
1075            }
1076        }
1077
1078        if !config.initial_delay.is_zero() {
1079            tokio::select! {
1080                _ = time::sleep(config.initial_delay) => {}
1081                _ = context.cancelled() => {
1082                    debug!(directory = config.directory, "File consumer cancelled during initial delay");
1083                    return Ok(());
1084                }
1085            }
1086        }
1087
1088        let mut interval = time::interval(config.delay);
1089
1090        loop {
1091            tokio::select! {
1092                _ = context.cancelled() => {
1093                    debug!(directory = config.directory, "File consumer received cancellation, stopping");
1094                    break;
1095                }
1096                _ = interval.tick() => {
1097                    if let Err(e) = poll_directory(
1098                        &config,
1099                        &context,
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            &filters,
1676            &mut seen,
1677            &in_process_locks,
1678            &idempotent_repo,
1679        )
1680        .await
1681        .unwrap();
1682        assert!(rx.try_recv().is_ok(), "first poll should emit file");
1683        assert!(rx.try_recv().is_err(), "should only emit once");
1684
1685        poll_directory(
1686            &config,
1687            &ctx,
1688            &filters,
1689            &mut seen,
1690            &in_process_locks,
1691            &idempotent_repo,
1692        )
1693        .await
1694        .unwrap();
1695        assert!(
1696            rx.try_recv().is_err(),
1697            "second poll should not re-emit seen file"
1698        );
1699    }
1700
1701    #[tokio::test]
1702    async fn noop_new_files_picked_up_after_first_poll() {
1703        let dir = tempfile::tempdir().unwrap();
1704        let file1 = dir.path().join("a.txt");
1705        tokio::fs::write(&file1, b"a").await.unwrap();
1706
1707        let uri = format!(
1708            "file:{}?noop=true&initialDelay=0&delay=50",
1709            dir.path().display()
1710        );
1711        let config = FileConfig::from_uri(&uri).unwrap();
1712        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1713        let token = CancellationToken::new();
1714        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1715
1716        let filters = CompiledFilters::default();
1717        let mut seen = std::collections::HashSet::new();
1718        let in_process_locks = std::sync::Arc::new(DashMap::new());
1719        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1720
1721        poll_directory(
1722            &config,
1723            &ctx,
1724            &filters,
1725            &mut seen,
1726            &in_process_locks,
1727            &idempotent_repo,
1728        )
1729        .await
1730        .unwrap();
1731        let _ = rx.try_recv();
1732
1733        let file2 = dir.path().join("b.txt");
1734        tokio::fs::write(&file2, b"b").await.unwrap();
1735
1736        poll_directory(
1737            &config,
1738            &ctx,
1739            &filters,
1740            &mut seen,
1741            &in_process_locks,
1742            &idempotent_repo,
1743        )
1744        .await
1745        .unwrap();
1746        assert!(
1747            rx.try_recv().is_ok(),
1748            "b.txt should be emitted on second poll"
1749        );
1750        assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1751    }
1752
1753    #[tokio::test]
1754    async fn test_file_consumer_include_filter() {
1755        let dir = tempfile::tempdir().unwrap();
1756        let dir_path = dir.path().to_str().unwrap();
1757
1758        std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1759        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1760
1761        let component = FileComponent::new();
1762        let ctx = NoOpComponentContext;
1763        let endpoint = component
1764            .create_endpoint(
1765                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1766                &ctx,
1767            )
1768            .unwrap();
1769        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1770
1771        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1772        let token = CancellationToken::new();
1773        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1774
1775        tokio::spawn(async move {
1776            consumer.start(ctx).await.unwrap();
1777        });
1778
1779        let mut received = Vec::new();
1780        let _ = tokio::time::timeout(Duration::from_millis(500), async {
1781            while let Some(envelope) = rx.recv().await {
1782                received.push(envelope.exchange);
1783                if received.len() == 1 {
1784                    break;
1785                }
1786            }
1787        })
1788        .await;
1789        token.cancel();
1790
1791        assert_eq!(received.len(), 1);
1792        let name = received[0]
1793            .input
1794            .header("CamelFileNameOnly")
1795            .and_then(|v| v.as_str())
1796            .unwrap();
1797        assert_eq!(name, "data.csv");
1798    }
1799
1800    #[tokio::test]
1801    async fn test_file_consumer_delete_mode() {
1802        let dir = tempfile::tempdir().unwrap();
1803        let dir_path = dir.path().to_str().unwrap();
1804
1805        std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1806
1807        let component = FileComponent::new();
1808        let ctx = NoOpComponentContext;
1809        let endpoint = component
1810            .create_endpoint(
1811                &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1812                &ctx,
1813            )
1814            .unwrap();
1815        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1816
1817        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1818        let token = CancellationToken::new();
1819        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1820
1821        tokio::spawn(async move {
1822            consumer.start(ctx).await.unwrap();
1823        });
1824
1825        let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1826        token.cancel();
1827
1828        tokio::time::sleep(Duration::from_millis(100)).await;
1829
1830        assert!(
1831            !dir.path().join("deleteme.txt").exists(),
1832            "File should be deleted"
1833        );
1834    }
1835
1836    #[tokio::test]
1837    async fn test_file_consumer_move_mode() {
1838        let dir = tempfile::tempdir().unwrap();
1839        tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1840            .await
1841            .unwrap();
1842
1843        let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1844        let config = FileConfig::from_uri(&uri).unwrap();
1845        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1846        let token = CancellationToken::new();
1847        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1848
1849        let filters = CompiledFilters::default();
1850        let mut seen = std::collections::HashSet::new();
1851        let in_process_locks = std::sync::Arc::new(DashMap::new());
1852        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1853
1854        poll_directory(
1855            &config,
1856            &ctx,
1857            &filters,
1858            &mut seen,
1859            &in_process_locks,
1860            &idempotent_repo,
1861        )
1862        .await
1863        .unwrap();
1864
1865        let ex = rx.try_recv().expect("should receive exchange");
1866        drop(ex);
1867
1868        assert!(
1869            !dir.path().join("moveme.txt").exists(),
1870            "Original file should be gone"
1871        );
1872        assert!(
1873            dir.path().join(".camel").join("moveme.txt").exists(),
1874            "File should be in .camel/"
1875        );
1876    }
1877
1878    #[tokio::test]
1879    async fn test_file_consumer_respects_cancellation() {
1880        let dir = tempfile::tempdir().unwrap();
1881        let dir_path = dir.path().to_str().unwrap();
1882
1883        let component = FileComponent::new();
1884        let ctx = NoOpComponentContext;
1885        let endpoint = component
1886            .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1887            .unwrap();
1888        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1889
1890        let (tx, _rx) = tokio::sync::mpsc::channel(16);
1891        let token = CancellationToken::new();
1892        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1893
1894        let handle = tokio::spawn(async move {
1895            consumer.start(ctx).await.unwrap();
1896        });
1897
1898        tokio::time::sleep(Duration::from_millis(150)).await;
1899        token.cancel();
1900
1901        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1902        assert!(
1903            result.is_ok(),
1904            "Consumer should have stopped after cancellation"
1905        );
1906    }
1907
1908    // -----------------------------------------------------------------------
1909    // Producer tests
1910    // -----------------------------------------------------------------------
1911
1912    #[tokio::test]
1913    async fn test_file_producer_writes_file() {
1914        use tower::ServiceExt;
1915
1916        let dir = tempfile::tempdir().unwrap();
1917        let dir_path = dir.path().to_str().unwrap();
1918
1919        let component = FileComponent::new();
1920        let ctx = NoOpComponentContext;
1921        let endpoint = component
1922            .create_endpoint(&format!("file:{dir_path}"), &ctx)
1923            .unwrap();
1924        let ctx = test_producer_ctx();
1925        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1926
1927        let mut exchange = Exchange::new(Message::new("file content"));
1928        exchange.input.set_header(
1929            "CamelFileName",
1930            serde_json::Value::String("output.txt".to_string()),
1931        );
1932
1933        let result = producer.oneshot(exchange).await.unwrap();
1934
1935        let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1936        assert_eq!(content, "file content");
1937
1938        assert!(result.input.header("CamelFileNameProduced").is_some());
1939    }
1940
1941    #[tokio::test]
1942    async fn test_file_producer_auto_create_dirs() {
1943        use tower::ServiceExt;
1944
1945        let dir = tempfile::tempdir().unwrap();
1946        let dir_path = dir.path().to_str().unwrap();
1947
1948        let component = FileComponent::new();
1949        let ctx = NoOpComponentContext;
1950        let endpoint = component
1951            .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1952            .unwrap();
1953        let ctx = test_producer_ctx();
1954        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1955
1956        let mut exchange = Exchange::new(Message::new("nested"));
1957        exchange.input.set_header(
1958            "CamelFileName",
1959            serde_json::Value::String("file.txt".to_string()),
1960        );
1961
1962        producer.oneshot(exchange).await.unwrap();
1963
1964        assert!(dir.path().join("sub/dir/file.txt").exists());
1965    }
1966
1967    #[tokio::test]
1968    async fn test_file_producer_file_exist_fail() {
1969        use tower::ServiceExt;
1970
1971        let dir = tempfile::tempdir().unwrap();
1972        let dir_path = dir.path().to_str().unwrap();
1973
1974        std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1975
1976        let component = FileComponent::new();
1977        let ctx = NoOpComponentContext;
1978        let endpoint = component
1979            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1980            .unwrap();
1981        let ctx = test_producer_ctx();
1982        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1983
1984        let mut exchange = Exchange::new(Message::new("new"));
1985        exchange.input.set_header(
1986            "CamelFileName",
1987            serde_json::Value::String("existing.txt".to_string()),
1988        );
1989
1990        let result = producer.oneshot(exchange).await;
1991        assert!(
1992            result.is_err(),
1993            "Should fail when file exists with Fail strategy"
1994        );
1995    }
1996
1997    #[tokio::test]
1998    async fn test_file_producer_file_exist_append() {
1999        use tower::ServiceExt;
2000
2001        let dir = tempfile::tempdir().unwrap();
2002        let dir_path = dir.path().to_str().unwrap();
2003
2004        std::fs::write(dir.path().join("append.txt"), "old").unwrap();
2005
2006        let component = FileComponent::new();
2007        let ctx = NoOpComponentContext;
2008        let endpoint = component
2009            .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
2010            .unwrap();
2011        let ctx = test_producer_ctx();
2012        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2013
2014        let mut exchange = Exchange::new(Message::new("new"));
2015        exchange.input.set_header(
2016            "CamelFileName",
2017            serde_json::Value::String("append.txt".to_string()),
2018        );
2019
2020        producer.oneshot(exchange).await.unwrap();
2021
2022        let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
2023        assert_eq!(content, "oldnew");
2024    }
2025
2026    #[tokio::test]
2027    async fn test_file_producer_temp_prefix() {
2028        use tower::ServiceExt;
2029
2030        let dir = tempfile::tempdir().unwrap();
2031        let dir_path = dir.path().to_str().unwrap();
2032
2033        let component = FileComponent::new();
2034        let ctx = NoOpComponentContext;
2035        let endpoint = component
2036            .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
2037            .unwrap();
2038        let ctx = test_producer_ctx();
2039        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2040
2041        let mut exchange = Exchange::new(Message::new("atomic write"));
2042        exchange.input.set_header(
2043            "CamelFileName",
2044            serde_json::Value::String("final.txt".to_string()),
2045        );
2046
2047        producer.oneshot(exchange).await.unwrap();
2048
2049        assert!(dir.path().join("final.txt").exists());
2050        assert!(!dir.path().join(".tmpfinal.txt").exists());
2051        let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
2052        assert_eq!(content, "atomic write");
2053    }
2054
2055    #[tokio::test]
2056    async fn file_producer_writes_nested_filename_override() {
2057        use tower::ServiceExt;
2058
2059        let dir = tempfile::tempdir().unwrap();
2060        let dir_path = dir.path().to_str().unwrap();
2061
2062        let component = FileComponent::new();
2063        let ctx = NoOpComponentContext;
2064        // Override is the default fileExist strategy.
2065        let endpoint = component
2066            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2067            .unwrap();
2068        let ctx = test_producer_ctx();
2069        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2070
2071        let mut exchange = Exchange::new(Message::new("nested body"));
2072        exchange.input.set_header(
2073            "CamelFileName",
2074            serde_json::Value::String("sub/dir/payload.bin".to_string()),
2075        );
2076
2077        producer.oneshot(exchange).await.unwrap();
2078
2079        let target = dir.path().join("sub").join("dir").join("payload.bin");
2080        assert!(target.exists(), "target file should exist at {:?}", target);
2081        let content = std::fs::read(&target).unwrap();
2082        assert_eq!(content, b"nested body");
2083        // No leftover temp file in the directory root (Bug C symptom: .tmp.sub/dir/payload.bin
2084        // path was being computed and the parent dir did not exist).
2085        assert!(
2086            !dir.path().join(".tmp.sub").exists(),
2087            "no stray temp path should be created at directory root"
2088        );
2089    }
2090
2091    #[tokio::test]
2092    async fn file_producer_writes_nested_filename_try_rename() {
2093        use tower::ServiceExt;
2094
2095        let dir = tempfile::tempdir().unwrap();
2096        let dir_path = dir.path().to_str().unwrap();
2097
2098        let component = FileComponent::new();
2099        let ctx = NoOpComponentContext;
2100        let endpoint = component
2101            .create_endpoint(
2102                &format!("file:{dir_path}?fileExist=TryRename&tempPrefix=.t."),
2103                &ctx,
2104            )
2105            .unwrap();
2106        let ctx = test_producer_ctx();
2107        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2108
2109        let mut exchange = Exchange::new(Message::new("try-rename nested"));
2110        exchange.input.set_header(
2111            "CamelFileName",
2112            serde_json::Value::String("deep/dir/x.bin".to_string()),
2113        );
2114
2115        producer.oneshot(exchange).await.unwrap();
2116
2117        let target = dir.path().join("deep").join("dir").join("x.bin");
2118        assert!(target.exists());
2119        assert_eq!(std::fs::read(&target).unwrap(), b"try-rename nested");
2120        // Temp file must not leak.
2121        assert!(
2122            !dir.path()
2123                .join("deep")
2124                .join("dir")
2125                .join(".t.x.bin")
2126                .exists(),
2127            "temp file should have been renamed away"
2128        );
2129    }
2130
2131    #[tokio::test]
2132    async fn file_producer_writes_nested_filename_fail_strategy() {
2133        use tower::ServiceExt;
2134
2135        let dir = tempfile::tempdir().unwrap();
2136        let dir_path = dir.path().to_str().unwrap();
2137
2138        let component = FileComponent::new();
2139        let ctx = NoOpComponentContext;
2140        let endpoint = component
2141            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2142            .unwrap();
2143        let ctx = test_producer_ctx();
2144        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2145
2146        let mut exchange = Exchange::new(Message::new("fail-strategy nested"));
2147        exchange.input.set_header(
2148            "CamelFileName",
2149            serde_json::Value::String("a/b/c.bin".to_string()),
2150        );
2151
2152        producer.oneshot(exchange).await.unwrap();
2153
2154        let target = dir.path().join("a").join("b").join("c.bin");
2155        assert!(target.exists());
2156        assert_eq!(std::fs::read(&target).unwrap(), b"fail-strategy nested");
2157    }
2158
2159    #[tokio::test]
2160    async fn test_file_producer_uses_filename_option() {
2161        use tower::ServiceExt;
2162
2163        let dir = tempfile::tempdir().unwrap();
2164        let dir_path = dir.path().to_str().unwrap();
2165
2166        let component = FileComponent::new();
2167        let ctx = NoOpComponentContext;
2168        let endpoint = component
2169            .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
2170            .unwrap();
2171        let ctx = test_producer_ctx();
2172        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2173
2174        let exchange = Exchange::new(Message::new("content"));
2175
2176        producer.oneshot(exchange).await.unwrap();
2177        assert!(dir.path().join("fixed.txt").exists());
2178    }
2179
2180    #[tokio::test]
2181    async fn test_file_producer_no_filename_errors() {
2182        use tower::ServiceExt;
2183
2184        let dir = tempfile::tempdir().unwrap();
2185        let dir_path = dir.path().to_str().unwrap();
2186
2187        let component = FileComponent::new();
2188        let ctx = NoOpComponentContext;
2189        let endpoint = component
2190            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2191            .unwrap();
2192        let ctx = test_producer_ctx();
2193        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2194
2195        let exchange = Exchange::new(Message::new("content"));
2196
2197        let result = producer.oneshot(exchange).await;
2198        assert!(result.is_err(), "Should error when no filename is provided");
2199    }
2200
2201    // -----------------------------------------------------------------------
2202    // Security tests - Path traversal protection
2203    // -----------------------------------------------------------------------
2204
2205    #[tokio::test]
2206    async fn test_file_producer_rejects_path_traversal_parent_directory() {
2207        use tower::ServiceExt;
2208
2209        let dir = tempfile::tempdir().unwrap();
2210        let dir_path = dir.path().to_str().unwrap();
2211
2212        // Create a subdirectory
2213        std::fs::create_dir(dir.path().join("subdir")).unwrap();
2214        std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2215
2216        let component = FileComponent::new();
2217        let ctx = NoOpComponentContext;
2218        let endpoint = component
2219            .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2220            .unwrap();
2221        let ctx = test_producer_ctx();
2222        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2223
2224        let mut exchange = Exchange::new(Message::new("malicious"));
2225        exchange.input.set_header(
2226            "CamelFileName",
2227            serde_json::Value::String("../secret.txt".to_string()),
2228        );
2229
2230        let result = producer.oneshot(exchange).await;
2231        assert!(result.is_err(), "Should reject path traversal attempt");
2232
2233        let err = result.unwrap_err();
2234        assert!(
2235            err.to_string().contains("outside"),
2236            "Error should mention path is outside base directory"
2237        );
2238    }
2239
2240    #[tokio::test]
2241    async fn test_file_producer_rejects_absolute_path_outside_base() {
2242        use tower::ServiceExt;
2243
2244        let dir = tempfile::tempdir().unwrap();
2245        let dir_path = dir.path().to_str().unwrap();
2246
2247        let component = FileComponent::new();
2248        let ctx = NoOpComponentContext;
2249        let endpoint = component
2250            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2251            .unwrap();
2252        let ctx = test_producer_ctx();
2253        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2254
2255        let mut exchange = Exchange::new(Message::new("malicious"));
2256        exchange.input.set_header(
2257            "CamelFileName",
2258            serde_json::Value::String("/etc/passwd".to_string()),
2259        );
2260
2261        let result = producer.oneshot(exchange).await;
2262        assert!(result.is_err(), "Should reject absolute path outside base");
2263    }
2264
2265    #[tokio::test]
2266    async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2267        use tower::ServiceExt;
2268
2269        let dir = tempfile::tempdir().unwrap();
2270        let dir_path = dir.path().to_str().unwrap();
2271
2272        let component = FileComponent::new();
2273        let ctx = NoOpComponentContext;
2274        let endpoint = component
2275            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2276            .unwrap();
2277        let ctx = test_producer_ctx();
2278        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2279
2280        let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2281        if outside_parent.exists() {
2282            std::fs::remove_dir_all(&outside_parent).unwrap();
2283        }
2284
2285        let mut exchange = Exchange::new(Message::new("malicious"));
2286        exchange.input.set_header(
2287            "CamelFileName",
2288            serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2289        );
2290
2291        let result = producer.oneshot(exchange).await;
2292        assert!(result.is_err(), "Should reject traversal path");
2293        assert!(
2294            !outside_parent.exists(),
2295            "Must not create outside directories before path validation"
2296        );
2297    }
2298
2299    #[cfg(unix)]
2300    #[tokio::test]
2301    async fn test_list_files_skips_symlink_cycle() {
2302        use std::os::unix::fs as unix_fs;
2303
2304        let dir = tempfile::tempdir().unwrap();
2305        let nested = dir.path().join("nested");
2306        std::fs::create_dir_all(&nested).unwrap();
2307        std::fs::write(nested.join("a.txt"), "x").unwrap();
2308        unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2309
2310        let files = list_files(dir.path(), true).await.unwrap();
2311        assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2312    }
2313
2314    // -----------------------------------------------------------------------
2315    // Large file streaming tests
2316    // -----------------------------------------------------------------------
2317
2318    #[tokio::test]
2319    #[ignore = "slow test: file polling (run with --ignored)"]
2320    async fn test_large_file_streaming_constant_memory() {
2321        use std::io::Write;
2322        use tempfile::NamedTempFile;
2323
2324        // Create a 150MB file (larger than 100MB limit)
2325        let mut temp_file = NamedTempFile::new().unwrap();
2326        let file_size = 150 * 1024 * 1024; // 150MB
2327        let chunk = vec![b'X'; 1024 * 1024]; // 1MB chunk
2328
2329        for _ in 0..150 {
2330            temp_file.write_all(&chunk).unwrap();
2331        }
2332        temp_file.flush().unwrap();
2333
2334        let dir = temp_file.path().parent().unwrap();
2335        let dir_path = dir.to_str().unwrap();
2336        let file_name = temp_file
2337            .path()
2338            .file_name()
2339            .unwrap()
2340            .to_str()
2341            .unwrap()
2342            .to_string();
2343
2344        // Read file as stream (should succeed with lazy evaluation)
2345        let component = FileComponent::new();
2346        let component_ctx = NoOpComponentContext;
2347        let endpoint = component
2348            .create_endpoint(
2349                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2350                &component_ctx,
2351            )
2352            .unwrap();
2353        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2354
2355        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2356        let token = CancellationToken::new();
2357        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2358
2359        tokio::spawn(async move {
2360            let _ = consumer.start(ctx).await;
2361        });
2362
2363        let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2364            rx.recv().await.unwrap().exchange
2365        })
2366        .await
2367        .expect("Should receive exchange");
2368        token.cancel();
2369
2370        // Verify body is a stream (not materialized)
2371        assert!(matches!(exchange.input.body, Body::Stream(_)));
2372
2373        // Verify we can read metadata without consuming
2374        if let Body::Stream(ref stream_body) = exchange.input.body {
2375            assert!(stream_body.metadata.size_hint.is_some());
2376            let size = stream_body.metadata.size_hint.unwrap();
2377            assert_eq!(size, file_size as u64);
2378        }
2379
2380        // Materializing should fail (exceeds 100MB limit)
2381        if let Body::Stream(stream_body) = exchange.input.body {
2382            let body = Body::Stream(stream_body);
2383            let result = body.into_bytes(100 * 1024 * 1024).await;
2384            assert!(result.is_err());
2385        }
2386
2387        // But we CAN read chunks one at a time (simulating line-by-line processing)
2388        // This demonstrates lazy evaluation - we don't need to load entire file
2389        let component2 = FileComponent::new();
2390        let endpoint2 = component2
2391            .create_endpoint(
2392                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2393                &component_ctx,
2394            )
2395            .unwrap();
2396        let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2397
2398        let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2399        let token2 = CancellationToken::new();
2400        let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2401
2402        tokio::spawn(async move {
2403            let _ = consumer2.start(ctx2).await;
2404        });
2405
2406        let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2407            rx2.recv().await.unwrap().exchange
2408        })
2409        .await
2410        .expect("Should receive exchange");
2411        token2.cancel();
2412
2413        if let Body::Stream(stream_body) = exchange2.input.body {
2414            let mut stream_lock = stream_body.stream.lock().await;
2415            let mut stream = stream_lock.take().unwrap();
2416
2417            // Read first chunk (size varies based on ReaderStream's buffer)
2418            if let Some(chunk_result) = stream.next().await {
2419                let chunk = chunk_result.unwrap();
2420                assert!(!chunk.is_empty());
2421                assert!(chunk.len() < file_size);
2422                // Memory usage is constant - we only have this chunk in memory, not 150MB
2423            }
2424        }
2425    }
2426
2427    // -----------------------------------------------------------------------
2428    // Streaming producer tests
2429    // -----------------------------------------------------------------------
2430
2431    #[tokio::test]
2432    async fn test_producer_writes_stream_body() {
2433        let dir = tempfile::tempdir().unwrap();
2434        let dir_path = dir.path().to_str().unwrap();
2435        let uri = format!("file:{dir_path}?fileName=out.txt");
2436
2437        let component = FileComponent::new();
2438        let ctx = NoOpComponentContext;
2439        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2440        let producer = endpoint
2441            .create_producer(rt(), &test_producer_ctx())
2442            .unwrap();
2443
2444        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2445            Ok(Bytes::from("hello ")),
2446            Ok(Bytes::from("streaming ")),
2447            Ok(Bytes::from("world")),
2448        ];
2449        let stream = futures::stream::iter(chunks);
2450        let body = Body::Stream(StreamBody {
2451            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2452            metadata: StreamMetadata {
2453                size_hint: None,
2454                content_type: None,
2455                origin: None,
2456            },
2457        });
2458
2459        let exchange = Exchange::new(Message::new(body));
2460        tower::ServiceExt::oneshot(producer, exchange)
2461            .await
2462            .unwrap();
2463
2464        let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2465            .await
2466            .unwrap();
2467        assert_eq!(content, "hello streaming world");
2468    }
2469
2470    #[tokio::test]
2471    async fn test_producer_stream_atomic_no_partial_on_error() {
2472        // If the stream errors mid-write, no file should exist at the target path
2473        let dir = tempfile::tempdir().unwrap();
2474        let dir_path = dir.path().to_str().unwrap();
2475        let uri = format!("file:{dir_path}?fileName=out.txt");
2476
2477        let component = FileComponent::new();
2478        let ctx = NoOpComponentContext;
2479        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2480        let producer = endpoint
2481            .create_producer(rt(), &test_producer_ctx())
2482            .unwrap();
2483
2484        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2485            Ok(Bytes::from("partial")),
2486            Err(CamelError::ProcessorError(
2487                "simulated stream error".to_string(),
2488            )),
2489        ];
2490        let stream = futures::stream::iter(chunks);
2491        let body = Body::Stream(StreamBody {
2492            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2493            metadata: StreamMetadata {
2494                size_hint: None,
2495                content_type: None,
2496                origin: None,
2497            },
2498        });
2499
2500        let exchange = Exchange::new(Message::new(body));
2501        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2502        assert!(
2503            result.is_err(),
2504            "expected error when stream fails mid-write"
2505        );
2506
2507        // Target file must NOT exist — write was aborted and temp file cleaned up
2508        assert!(
2509            !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2510            "partial file must not exist after failed write"
2511        );
2512
2513        // Temp file must also be cleaned up
2514        assert!(
2515            !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2516            "temp file must be cleaned up after failed write"
2517        );
2518    }
2519
2520    #[tokio::test]
2521    async fn test_producer_stream_append() {
2522        let dir = tempfile::tempdir().unwrap();
2523        let dir_path = dir.path().to_str().unwrap();
2524        let target = format!("{dir_path}/out.txt");
2525
2526        // Pre-create file with initial content
2527        tokio::fs::write(&target, b"line1\n").await.unwrap();
2528
2529        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2530        let component = FileComponent::new();
2531        let ctx = NoOpComponentContext;
2532        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2533        let producer = endpoint
2534            .create_producer(rt(), &test_producer_ctx())
2535            .unwrap();
2536
2537        let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2538        let stream = futures::stream::iter(chunks);
2539        let body = Body::Stream(StreamBody {
2540            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2541            metadata: StreamMetadata {
2542                size_hint: None,
2543                content_type: None,
2544                origin: None,
2545            },
2546        });
2547
2548        let exchange = Exchange::new(Message::new(body));
2549        tower::ServiceExt::oneshot(producer, exchange)
2550            .await
2551            .unwrap();
2552
2553        let content = tokio::fs::read_to_string(&target).await.unwrap();
2554        assert_eq!(content, "line1\nline2\n");
2555    }
2556
2557    #[tokio::test]
2558    async fn test_producer_stream_append_partial_on_error() {
2559        // Append is inherently non-atomic: if the stream errors mid-write,
2560        // the file will contain partial data. This test documents that behavior.
2561        let dir = tempfile::tempdir().unwrap();
2562        let dir_path = dir.path().to_str().unwrap();
2563        let target = format!("{dir_path}/out.txt");
2564
2565        // Pre-create file with initial content
2566        tokio::fs::write(&target, b"initial\n").await.unwrap();
2567
2568        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2569        let component = FileComponent::new();
2570        let ctx = NoOpComponentContext;
2571        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2572        let producer = endpoint
2573            .create_producer(rt(), &test_producer_ctx())
2574            .unwrap();
2575
2576        // Stream with an error in the middle
2577        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2578            Ok(Bytes::from("partial-")), // This will be written
2579            Err(CamelError::ProcessorError("stream error".to_string())), // This causes failure
2580            Ok(Bytes::from("never-written")), // This won't be reached
2581        ];
2582        let stream = futures::stream::iter(chunks);
2583        let body = Body::Stream(StreamBody {
2584            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2585            metadata: StreamMetadata {
2586                size_hint: None,
2587                content_type: None,
2588                origin: None,
2589            },
2590        });
2591
2592        let exchange = Exchange::new(Message::new(body));
2593        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2594
2595        // 1. Producer must return an error
2596        assert!(
2597            result.is_err(),
2598            "expected error when stream fails during append"
2599        );
2600
2601        // 2. File must contain initial content + partial data written before the error
2602        let content = tokio::fs::read_to_string(&target).await.unwrap();
2603        assert_eq!(
2604            content, "initial\npartial-",
2605            "append leaves partial data on stream error (non-atomic by nature)"
2606        );
2607    }
2608
2609    #[tokio::test]
2610    async fn test_producer_stream_already_consumed_errors() {
2611        let dir = tempfile::tempdir().unwrap();
2612        let dir_path = dir.path().to_str().unwrap();
2613        let uri = format!("file:{dir_path}?fileName=out.txt");
2614
2615        let component = FileComponent::new();
2616        let ctx = NoOpComponentContext;
2617        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2618        let producer = endpoint
2619            .create_producer(rt(), &test_producer_ctx())
2620            .unwrap();
2621
2622        // Mutex holds None -> stream already consumed
2623        type MaybeStream = std::sync::Arc<
2624            tokio::sync::Mutex<
2625                Option<
2626                    std::pin::Pin<
2627                        Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2628                    >,
2629                >,
2630            >,
2631        >;
2632        let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2633        let body = Body::Stream(StreamBody {
2634            stream: arc,
2635            metadata: StreamMetadata {
2636                size_hint: None,
2637                content_type: None,
2638                origin: None,
2639            },
2640        });
2641
2642        let exchange = Exchange::new(Message::new(body));
2643        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2644        assert!(
2645            result.is_err(),
2646            "expected error for already-consumed stream"
2647        );
2648    }
2649
2650    // -----------------------------------------------------------------------
2651    // GlobalConfig tests - apply_global_defaults behavior
2652    // -----------------------------------------------------------------------
2653
2654    #[test]
2655    fn test_global_config_applied_to_endpoint() {
2656        // Global config with non-default values
2657        let global = FileGlobalConfig::default()
2658            .with_delay_ms(2000)
2659            .with_initial_delay_ms(5000)
2660            .with_read_timeout_ms(60_000)
2661            .with_write_timeout_ms(45_000);
2662        let component = FileComponent::with_config(global);
2663        let ctx = NoOpComponentContext;
2664        // URI uses no explicit delay/timeout params → macro defaults apply
2665        let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2666        // We cannot call endpoint.config directly (FileEndpoint is private),
2667        // but we can test apply_global_defaults on FileConfig directly:
2668        let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2669        let global2 = FileGlobalConfig::default()
2670            .with_delay_ms(2000)
2671            .with_initial_delay_ms(5000)
2672            .with_read_timeout_ms(60_000)
2673            .with_write_timeout_ms(45_000);
2674        config.apply_global_defaults(&global2);
2675        assert_eq!(config.delay, Duration::from_millis(2000));
2676        assert_eq!(config.initial_delay, Duration::from_millis(5000));
2677        assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2678        assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2679        // endpoint creation succeeds too
2680        let _ = endpoint; // just verify create_endpoint didn't fail
2681    }
2682
2683    #[test]
2684    fn test_uri_param_wins_over_global_config() {
2685        // URI explicitly sets delay=1000 (NOT the 500ms macro default)
2686        let mut config =
2687            FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2688        // Global config would want 3000ms delay
2689        let global = FileGlobalConfig::default()
2690            .with_delay_ms(3000)
2691            .with_initial_delay_ms(4000);
2692        config.apply_global_defaults(&global);
2693        // URI value of 1000ms must be preserved (not replaced by 3000ms)
2694        assert_eq!(config.delay, Duration::from_millis(1000));
2695        // URI value of 2000ms must be preserved (not replaced by 4000ms)
2696        assert_eq!(config.initial_delay, Duration::from_millis(2000));
2697        // read_timeout was not set by URI → macro default (30000) → global wins if different
2698        // (read_timeout stays at 30000 since global has same default = 30000)
2699        assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2700    }
2701
2702    #[tokio::test]
2703    async fn test_file_producer_filename_simple_language_from_header() {
2704        use tower::ServiceExt;
2705
2706        let dir = tempfile::tempdir().unwrap();
2707        let dir_path = dir.path().to_str().unwrap();
2708
2709        let component = FileComponent::new();
2710        let ctx = NoOpComponentContext;
2711        let endpoint = component
2712            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2713            .unwrap();
2714        let ctx = test_producer_ctx();
2715        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2716
2717        let mut exchange = Exchange::new(Message::new("content"));
2718        exchange
2719            .input
2720            .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2721        exchange.input.set_header(
2722            "CamelFileName",
2723            serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2724        );
2725
2726        producer.oneshot(exchange).await.unwrap();
2727
2728        assert!(
2729            dir.path().join("test-42.txt").exists(),
2730            "fileName should have been evaluated from Simple Language expression"
2731        );
2732        let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2733        assert_eq!(content, "content");
2734    }
2735
2736    #[tokio::test]
2737    async fn test_file_producer_filename_simple_language_from_uri_param() {
2738        use tower::ServiceExt;
2739
2740        let dir = tempfile::tempdir().unwrap();
2741        let dir_path = dir.path().to_str().unwrap();
2742
2743        let component = FileComponent::new();
2744        let ctx = NoOpComponentContext;
2745        let endpoint = component
2746            .create_endpoint(
2747                &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2748                &ctx,
2749            )
2750            .unwrap();
2751        let ctx = test_producer_ctx();
2752        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2753
2754        let mut exchange = Exchange::new(Message::new("data"));
2755        exchange
2756            .input
2757            .set_header("id", serde_json::Value::String("abc".to_string()));
2758
2759        producer.oneshot(exchange).await.unwrap();
2760
2761        assert!(
2762            dir.path().join("msg-abc.dat").exists(),
2763            "fileName URI param should have been evaluated from Simple Language expression"
2764        );
2765    }
2766
2767    #[tokio::test]
2768    async fn test_file_producer_filename_literal_without_expression() {
2769        use tower::ServiceExt;
2770
2771        let dir = tempfile::tempdir().unwrap();
2772        let dir_path = dir.path().to_str().unwrap();
2773
2774        let component = FileComponent::new();
2775        let ctx = NoOpComponentContext;
2776        let endpoint = component
2777            .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2778            .unwrap();
2779        let ctx = test_producer_ctx();
2780        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2781
2782        let exchange = Exchange::new(Message::new("data"));
2783        producer.oneshot(exchange).await.unwrap();
2784
2785        assert!(
2786            dir.path().join("plain.txt").exists(),
2787            "literal fileName without expressions should still work"
2788        );
2789    }
2790
2791    // -----------------------------------------------------------------------
2792    // Config validation tests
2793    // -----------------------------------------------------------------------
2794
2795    #[test]
2796    fn test_rejects_path_traversal_in_move_to() {
2797        let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2798        assert!(result.is_err(), "should reject path traversal in move_to");
2799        let err = result.unwrap_err().to_string();
2800        assert!(
2801            err.contains("path traversal"),
2802            "error should mention path traversal: {err}"
2803        );
2804    }
2805
2806    #[test]
2807    fn test_rejects_absolute_move_to() {
2808        let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2809        assert!(result.is_err(), "should reject absolute move_to");
2810        let err = result.unwrap_err().to_string();
2811        assert!(
2812            err.contains("relative path") || err.contains("Invalid URI"),
2813            "error should mention invalid move_to path: {err}"
2814        );
2815    }
2816
2817    #[test]
2818    fn test_rejects_path_traversal_in_temp_prefix() {
2819        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2820        assert!(
2821            result.is_err(),
2822            "should reject path traversal in temp_prefix"
2823        );
2824        let err = result.unwrap_err().to_string();
2825        assert!(
2826            err.contains("path traversal"),
2827            "error should mention path traversal: {err}"
2828        );
2829    }
2830
2831    #[test]
2832    fn test_rejects_temp_prefix_with_path_separator() {
2833        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2834        assert!(result.is_err(), "should reject temp_prefix with separator");
2835        let err = result.unwrap_err().to_string();
2836        assert!(
2837            err.contains("plain filename prefix"),
2838            "error should mention plain filename prefix restriction: {err}"
2839        );
2840    }
2841
2842    #[test]
2843    fn test_rejects_absolute_temp_prefix() {
2844        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2845        assert!(result.is_err(), "should reject absolute temp_prefix");
2846        let err = result.unwrap_err().to_string();
2847        assert!(
2848            err.contains("plain filename prefix"),
2849            "error should mention plain filename prefix restriction: {err}"
2850        );
2851    }
2852
2853    #[test]
2854    fn test_rejects_null_byte_in_temp_prefix() {
2855        let config = FileConfig {
2856            directory: "/tmp/inbox".into(),
2857            delay: Duration::from_millis(500),
2858            delay_ms: 500,
2859            initial_delay: Duration::from_millis(1000),
2860            initial_delay_ms: 1000,
2861            noop: false,
2862            delete: false,
2863            move_to: None,
2864            file_name: Some("ok.txt".into()),
2865            include: None,
2866            exclude: None,
2867            ant_include: None,
2868            ant_exclude: None,
2869            include_ext: None,
2870            exclude_ext: None,
2871            recursive: false,
2872            file_exist: FileExistStrategy::Override,
2873            read_lock_strategy: ReadLockStrategy::None,
2874            idempotent_key: IdempotentKey::None,
2875            done_file_name: None,
2876            charset: None,
2877            temp_prefix: Some("tmp\0".into()),
2878            durable: false,
2879            auto_create: true,
2880            starting_directory_must_exist: false,
2881            read_timeout: Duration::from_millis(30_000),
2882            read_timeout_ms: 30_000,
2883            write_timeout: Duration::from_millis(30_000),
2884            write_timeout_ms: 30_000,
2885            max_depth: usize::MAX,
2886            min_depth: 0,
2887            max_messages_per_poll: 0,
2888            eager_max_messages_per_poll: true,
2889            shuffle: false,
2890            sort_spec: None,
2891            cleanup_stale_temps: true,
2892        };
2893
2894        let result = config.validate();
2895        assert!(result.is_err(), "should reject null byte in temp_prefix");
2896    }
2897
2898    #[test]
2899    fn test_rejects_null_byte_in_filename() {
2900        // Null bytes in URI params are typically URL-encoded, so we test via
2901        // direct config construction to simulate the validation logic.
2902        let config = FileConfig {
2903            directory: "/tmp/inbox".into(),
2904            delay: Duration::from_millis(500),
2905            delay_ms: 500,
2906            initial_delay: Duration::from_millis(1000),
2907            initial_delay_ms: 1000,
2908            noop: false,
2909            delete: false,
2910            move_to: None,
2911            file_name: Some("foo\0bar".into()),
2912            include: None,
2913            exclude: None,
2914            ant_include: None,
2915            ant_exclude: None,
2916            include_ext: None,
2917            exclude_ext: None,
2918            recursive: false,
2919            file_exist: FileExistStrategy::Override,
2920            read_lock_strategy: ReadLockStrategy::None,
2921            idempotent_key: IdempotentKey::None,
2922            done_file_name: None,
2923            charset: None,
2924            temp_prefix: None,
2925            durable: false,
2926            auto_create: true,
2927            starting_directory_must_exist: false,
2928            read_timeout: Duration::from_millis(30_000),
2929            read_timeout_ms: 30_000,
2930            write_timeout: Duration::from_millis(30_000),
2931            write_timeout_ms: 30_000,
2932            max_depth: usize::MAX,
2933            min_depth: 0,
2934            max_messages_per_poll: 0,
2935            eager_max_messages_per_poll: true,
2936            shuffle: false,
2937            sort_spec: None,
2938            cleanup_stale_temps: true,
2939        };
2940        let result = config.validate();
2941        assert!(result.is_err(), "should reject null byte in filename");
2942        let err = result.unwrap_err().to_string();
2943        assert!(
2944            err.contains("null"),
2945            "error should mention null bytes: {err}"
2946        );
2947    }
2948
2949    #[test]
2950    fn test_rejects_empty_filename() {
2951        let config = FileConfig {
2952            directory: "/tmp/inbox".into(),
2953            delay: Duration::from_millis(500),
2954            delay_ms: 500,
2955            initial_delay: Duration::from_millis(1000),
2956            initial_delay_ms: 1000,
2957            noop: false,
2958            delete: false,
2959            move_to: None,
2960            file_name: Some("".into()),
2961            include: None,
2962            exclude: None,
2963            ant_include: None,
2964            ant_exclude: None,
2965            include_ext: None,
2966            exclude_ext: None,
2967            recursive: false,
2968            file_exist: FileExistStrategy::Override,
2969            read_lock_strategy: ReadLockStrategy::None,
2970            idempotent_key: IdempotentKey::None,
2971            done_file_name: None,
2972            charset: None,
2973            temp_prefix: None,
2974            durable: false,
2975            auto_create: true,
2976            starting_directory_must_exist: false,
2977            read_timeout: Duration::from_millis(30_000),
2978            read_timeout_ms: 30_000,
2979            write_timeout: Duration::from_millis(30_000),
2980            write_timeout_ms: 30_000,
2981            max_depth: usize::MAX,
2982            min_depth: 0,
2983            max_messages_per_poll: 0,
2984            eager_max_messages_per_poll: true,
2985            shuffle: false,
2986            sort_spec: None,
2987            cleanup_stale_temps: true,
2988        };
2989        let result = config.validate();
2990        assert!(result.is_err(), "should reject empty filename");
2991        let err = result.unwrap_err().to_string();
2992        assert!(
2993            err.contains("empty") || err.contains("must not"),
2994            "error should mention empty: {err}"
2995        );
2996    }
2997
2998    #[test]
2999    fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
3000        let result =
3001            FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
3002        assert!(
3003            result.is_err(),
3004            "should reject non-existent directory when startingDirectoryMustExist=true"
3005        );
3006        let err = result.unwrap_err().to_string();
3007        assert!(
3008            err.contains("does not exist"),
3009            "error should mention directory does not exist: {err}"
3010        );
3011    }
3012
3013    #[test]
3014    fn test_accepts_existing_directory_when_starting_directory_must_exist() {
3015        let dir = tempfile::tempdir().unwrap();
3016        let dir_path = dir.path().to_str().unwrap();
3017        let result =
3018            FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
3019        assert!(
3020            result.is_ok(),
3021            "should accept existing directory when startingDirectoryMustExist=true: {:?}",
3022            result.err()
3023        );
3024    }
3025
3026    #[test]
3027    fn test_valid_config_passes() {
3028        let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
3029        assert!(cfg.validate().is_ok());
3030    }
3031
3032    #[test]
3033    fn test_file_exist_strategy_rejects_unknown() {
3034        // Unknown strategy values should be rejected by from_str
3035        let result = FileExistStrategy::from_str("BogusValue");
3036        assert!(
3037            result.is_err(),
3038            "unknown FileExistStrategy should return Err"
3039        );
3040    }
3041
3042    #[test]
3043    fn test_path_contains_traversal_detects_parent_dir() {
3044        assert!(path_contains_traversal("../etc/passwd"));
3045        assert!(path_contains_traversal("foo/../bar"));
3046        assert!(path_contains_traversal(".."));
3047        assert!(path_contains_traversal("a/b/../../../c"));
3048    }
3049
3050    #[test]
3051    fn test_path_contains_traversal_accepts_safe_paths() {
3052        assert!(!path_contains_traversal("safe/path"));
3053        assert!(!path_contains_traversal("/absolute/path"));
3054        assert!(!path_contains_traversal("filename.txt"));
3055        assert!(!path_contains_traversal(".hidden"));
3056        assert!(!path_contains_traversal(""));
3057    }
3058
3059    // -----------------------------------------------------------------------
3060    // File modification detection tests (C-20)
3061    // -----------------------------------------------------------------------
3062
3063    #[tokio::test]
3064    async fn test_stream_detects_file_modified_during_read() {
3065        use futures::StreamExt;
3066
3067        let dir = tempfile::tempdir().unwrap();
3068        let file_path = dir.path().join("mutable.txt");
3069        std::fs::write(&file_path, b"initial content here").unwrap();
3070
3071        // Capture initial metadata
3072        let initial_meta = std::fs::metadata(&file_path).unwrap();
3073        let initial_size = initial_meta.len();
3074        let initial_mtime = initial_meta.modified().ok();
3075
3076        // Create a raw file stream (simulating what the consumer does)
3077        let file = tokio::fs::File::open(&file_path).await.unwrap();
3078        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3079
3080        let mut stream = ModificationDetectingStream::new(
3081            raw_stream,
3082            file_path.clone(),
3083            initial_size,
3084            initial_mtime,
3085        );
3086
3087        // Read a chunk (file not modified yet — should succeed)
3088        let chunk = stream.next().await;
3089        assert!(chunk.is_some(), "should produce at least one chunk");
3090        assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
3091
3092        // Modify the file (change content/size)
3093        std::fs::write(&file_path, b"modified content - different size!!").unwrap();
3094
3095        // Drain remaining chunks — the stream should produce an error after EOF
3096        let mut got_error = false;
3097        while let Some(result) = stream.next().await {
3098            if let Err(err) = result {
3099                let msg = err.to_string();
3100                assert!(
3101                    msg.contains("file modified during read"),
3102                    "expected modification error, got: {msg}"
3103                );
3104                got_error = true;
3105                break;
3106            }
3107        }
3108
3109        assert!(
3110            got_error,
3111            "stream should detect file was modified during read"
3112        );
3113    }
3114
3115    #[tokio::test]
3116    async fn test_stream_succeeds_when_file_not_modified() {
3117        use futures::StreamExt;
3118
3119        let dir = tempfile::tempdir().unwrap();
3120        let file_path = dir.path().join("stable.txt");
3121        std::fs::write(&file_path, b"stable content").unwrap();
3122
3123        let initial_meta = std::fs::metadata(&file_path).unwrap();
3124        let initial_size = initial_meta.len();
3125        let initial_mtime = initial_meta.modified().ok();
3126
3127        let file = tokio::fs::File::open(&file_path).await.unwrap();
3128        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3129
3130        let mut stream = ModificationDetectingStream::new(
3131            raw_stream,
3132            file_path.clone(),
3133            initial_size,
3134            initial_mtime,
3135        );
3136
3137        // Drain entire stream — should produce no errors
3138        let mut all_ok = true;
3139        while let Some(result) = stream.next().await {
3140            if result.is_err() {
3141                all_ok = false;
3142                break;
3143            }
3144        }
3145
3146        assert!(
3147            all_ok,
3148            "stream should complete without errors when file is not modified"
3149        );
3150    }
3151
3152    // -----------------------------------------------------------------------
3153    // SortSpec::from_str tests
3154    // -----------------------------------------------------------------------
3155
3156    #[test]
3157    fn test_sort_spec_bare_field() {
3158        let spec: SortSpec = "file:name".parse().unwrap();
3159        assert_eq!(spec.groups.len(), 1);
3160        let g = &spec.groups[0];
3161        assert_eq!(g.field, SortField::Name);
3162        assert!(!g.reverse);
3163        assert!(!g.ignore_case);
3164    }
3165
3166    #[test]
3167    fn test_sort_spec_reverse() {
3168        let spec: SortSpec = "reverse:file:length".parse().unwrap();
3169        assert_eq!(spec.groups.len(), 1);
3170        let g = &spec.groups[0];
3171        assert_eq!(g.field, SortField::Length);
3172        assert!(g.reverse);
3173        assert!(!g.ignore_case);
3174    }
3175
3176    #[test]
3177    fn test_sort_spec_ignore_case() {
3178        let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
3179        assert_eq!(spec.groups.len(), 1);
3180        let g = &spec.groups[0];
3181        assert_eq!(g.field, SortField::Name);
3182        assert!(!g.reverse);
3183        assert!(g.ignore_case);
3184    }
3185
3186    #[test]
3187    fn test_sort_spec_reverse_ignore_case() {
3188        let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
3189        assert_eq!(spec.groups.len(), 1);
3190        let g = &spec.groups[0];
3191        assert_eq!(g.field, SortField::Modified);
3192        assert!(g.reverse);
3193        assert!(g.ignore_case);
3194    }
3195
3196    #[test]
3197    fn test_sort_spec_wrong_order_rejection() {
3198        let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
3199        assert!(result.is_err());
3200        let err = result.unwrap_err();
3201        assert!(
3202            err.contains("reverse must precede"),
3203            "expected 'reverse must precede' error, got: {err}"
3204        );
3205    }
3206
3207    #[test]
3208    fn test_sort_spec_multi_group() {
3209        let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
3210        assert_eq!(spec.groups.len(), 2);
3211        assert_eq!(spec.groups[0].field, SortField::Name);
3212        assert!(!spec.groups[0].reverse);
3213        assert_eq!(spec.groups[1].field, SortField::Length);
3214        assert!(spec.groups[1].reverse);
3215    }
3216
3217    #[test]
3218    fn test_sort_spec_unsupported_field() {
3219        let result: Result<SortSpec, _> = "file:unknown".parse();
3220        assert!(result.is_err());
3221        let err = result.unwrap_err();
3222        assert!(
3223            err.contains("unsupported"),
3224            "expected 'unsupported' error, got: {err}"
3225        );
3226    }
3227
3228    #[test]
3229    fn test_sort_spec_empty_string() {
3230        let result: Result<SortSpec, _> = "".parse();
3231        assert!(result.is_err());
3232        let err = result.unwrap_err();
3233        assert!(
3234            err.contains("at least one group"),
3235            "expected 'at least one group' error, got: {err}"
3236        );
3237    }
3238
3239    #[tokio::test]
3240    async fn test_max_depth_limits_recursion() {
3241        let dir = tempfile::tempdir().unwrap();
3242        let base = dir.path();
3243        fs::create_dir_all(base.join("sub/deep")).await.unwrap();
3244        fs::write(base.join("root.txt"), b"r").await.unwrap();
3245        fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
3246        fs::write(base.join("sub/deep/leaf.txt"), b"l")
3247            .await
3248            .unwrap();
3249
3250        let config = FileConfig::from_uri(&format!(
3251            "file://{}?recursive=true&maxDepth=2",
3252            base.display()
3253        ))
3254        .unwrap();
3255        let filters = CompiledFilters::compile(&config).unwrap();
3256        let mut seen = HashSet::new();
3257        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3258            .await
3259            .unwrap()
3260            .candidates;
3261        let names: Vec<String> = candidates
3262            .iter()
3263            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3264            .collect();
3265        assert!(names.contains(&"root.txt".to_string()));
3266        assert!(names.contains(&"mid.txt".to_string()));
3267        assert!(!names.contains(&"leaf.txt".to_string()));
3268    }
3269
3270    #[tokio::test]
3271    async fn test_min_depth_skips_base_files() {
3272        let dir = tempfile::tempdir().unwrap();
3273        let base = dir.path();
3274        fs::create_dir_all(base.join("sub")).await.unwrap();
3275        fs::write(base.join("root.txt"), b"r").await.unwrap();
3276        fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3277
3278        let config = FileConfig::from_uri(&format!(
3279            "file://{}?recursive=true&minDepth=2",
3280            base.display()
3281        ))
3282        .unwrap();
3283        let filters = CompiledFilters::compile(&config).unwrap();
3284        let mut seen = HashSet::new();
3285        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3286            .await
3287            .unwrap()
3288            .candidates;
3289        let names: Vec<String> = candidates
3290            .iter()
3291            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3292            .collect();
3293        assert!(!names.contains(&"root.txt".to_string()));
3294        assert!(names.contains(&"deep.txt".to_string()));
3295    }
3296
3297    #[tokio::test]
3298    async fn test_include_ext_filter() {
3299        let dir = tempfile::tempdir().unwrap();
3300        let base = dir.path();
3301        fs::write(base.join("data.txt"), b"t").await.unwrap();
3302        fs::write(base.join("data.csv"), b"c").await.unwrap();
3303        fs::write(base.join("data.json"), b"j").await.unwrap();
3304
3305        let config =
3306            FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3307        let filters = CompiledFilters::compile(&config).unwrap();
3308        let mut seen = HashSet::new();
3309        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3310            .await
3311            .unwrap()
3312            .candidates;
3313        let names: Vec<String> = candidates
3314            .iter()
3315            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3316            .collect();
3317        assert_eq!(names.len(), 2);
3318        assert!(names.contains(&"data.txt".to_string()));
3319        assert!(names.contains(&"data.csv".to_string()));
3320    }
3321
3322    #[tokio::test]
3323    async fn test_ant_include_glob() {
3324        let dir = tempfile::tempdir().unwrap();
3325        let base = dir.path();
3326        fs::write(base.join("report.txt"), b"r").await.unwrap();
3327        fs::write(base.join("data.csv"), b"d").await.unwrap();
3328        fs::write(base.join("image.png"), b"i").await.unwrap();
3329
3330        let config =
3331            FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3332                .unwrap();
3333        let filters = CompiledFilters::compile(&config).unwrap();
3334        let mut seen = HashSet::new();
3335        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3336            .await
3337            .unwrap()
3338            .candidates;
3339        let names: Vec<String> = candidates
3340            .iter()
3341            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3342            .collect();
3343        assert_eq!(names.len(), 2);
3344        assert!(names.contains(&"report.txt".to_string()));
3345        assert!(names.contains(&"data.csv".to_string()));
3346    }
3347
3348    #[tokio::test]
3349    async fn test_exclude_ext_filter() {
3350        let dir = tempfile::tempdir().unwrap();
3351        let base = dir.path();
3352        fs::write(base.join("keep.txt"), b"k").await.unwrap();
3353        fs::write(base.join("skip.log"), b"s").await.unwrap();
3354
3355        let config =
3356            FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3357        let filters = CompiledFilters::compile(&config).unwrap();
3358        let mut seen = HashSet::new();
3359        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3360            .await
3361            .unwrap()
3362            .candidates;
3363        let names: Vec<String> = candidates
3364            .iter()
3365            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3366            .collect();
3367        assert_eq!(names.len(), 1);
3368        assert!(names.contains(&"keep.txt".to_string()));
3369    }
3370
3371    #[tokio::test]
3372    async fn test_ant_exclude_glob() {
3373        let dir = tempfile::tempdir().unwrap();
3374        let base = dir.path();
3375        fs::write(base.join("report.txt"), b"r").await.unwrap();
3376        fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3377
3378        let config =
3379            FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3380        let filters = CompiledFilters::compile(&config).unwrap();
3381        let mut seen = HashSet::new();
3382        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3383            .await
3384            .unwrap()
3385            .candidates;
3386        let names: Vec<String> = candidates
3387            .iter()
3388            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3389            .collect();
3390        assert_eq!(names.len(), 1);
3391        assert!(names.contains(&"report.txt".to_string()));
3392    }
3393
3394    #[tokio::test]
3395    async fn test_done_file_consumer_skips_without_marker() {
3396        let dir = tempfile::tempdir().unwrap();
3397        let base = dir.path();
3398        fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3399        fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3400        fs::write(base.join("pending.txt"), b"pending")
3401            .await
3402            .unwrap();
3403
3404        let config = FileConfig::from_uri(&format!(
3405            "file://{}?doneFileName=${{file:name}}.done",
3406            base.display()
3407        ))
3408        .unwrap();
3409        let filters = CompiledFilters::compile(&config).unwrap();
3410        let mut seen = HashSet::new();
3411        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3412            .await
3413            .unwrap()
3414            .candidates;
3415        let names: Vec<String> = candidates
3416            .iter()
3417            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3418            .collect();
3419        assert!(names.contains(&"ready.txt".to_string()));
3420        assert!(!names.contains(&"pending.txt".to_string()));
3421        assert!(!names.contains(&"ready.txt.done".to_string()));
3422    }
3423
3424    #[tokio::test]
3425    async fn test_done_file_static_pattern() {
3426        let dir = tempfile::tempdir().unwrap();
3427        let base = dir.path();
3428        fs::write(base.join("data.txt"), b"d").await.unwrap();
3429        fs::write(base.join("ready"), b"").await.unwrap();
3430
3431        let config =
3432            FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3433        let filters = CompiledFilters::compile(&config).unwrap();
3434        let mut seen = HashSet::new();
3435        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3436            .await
3437            .unwrap()
3438            .candidates;
3439        assert_eq!(candidates.len(), 1);
3440        assert_eq!(
3441            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3442            "data.txt"
3443        );
3444    }
3445
3446    // -----------------------------------------------------------------------
3447    // Sort + shuffle tests
3448    // -----------------------------------------------------------------------
3449
3450    #[tokio::test]
3451    async fn test_sort_by_name() {
3452        let dir = tempfile::tempdir().unwrap();
3453        let base = dir.path();
3454        fs::write(base.join("c.txt"), b"3").await.unwrap();
3455        fs::write(base.join("a.txt"), b"1").await.unwrap();
3456        fs::write(base.join("b.txt"), b"2").await.unwrap();
3457
3458        let config =
3459            FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3460        let filters = CompiledFilters::compile(&config).unwrap();
3461        let mut seen = HashSet::new();
3462        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3463            .await
3464            .unwrap()
3465            .candidates;
3466        apply_sort_and_limit(&mut candidates, &config);
3467        let names: Vec<&str> = candidates
3468            .iter()
3469            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3470            .collect();
3471        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3472    }
3473
3474    #[tokio::test]
3475    async fn test_sort_by_length() {
3476        let dir = tempfile::tempdir().unwrap();
3477        let base = dir.path();
3478        fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3479        fs::write(base.join("small.txt"), b"x").await.unwrap();
3480
3481        let config =
3482            FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3483        let filters = CompiledFilters::compile(&config).unwrap();
3484        let mut seen = HashSet::new();
3485        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3486            .await
3487            .unwrap()
3488            .candidates;
3489        apply_sort_and_limit(&mut candidates, &config);
3490        let names: Vec<&str> = candidates
3491            .iter()
3492            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3493            .collect();
3494        assert_eq!(names, vec!["small.txt", "big.txt"]);
3495    }
3496
3497    #[tokio::test]
3498    async fn test_sort_by_reverse_name() {
3499        let dir = tempfile::tempdir().unwrap();
3500        let base = dir.path();
3501        fs::write(base.join("a.txt"), b"1").await.unwrap();
3502        fs::write(base.join("b.txt"), b"2").await.unwrap();
3503        fs::write(base.join("c.txt"), b"3").await.unwrap();
3504
3505        let config = FileConfig::from_uri(&format!(
3506            "file://{}?sortBy=reverse:file:name",
3507            base.display()
3508        ))
3509        .unwrap();
3510        let filters = CompiledFilters::compile(&config).unwrap();
3511        let mut seen = HashSet::new();
3512        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3513            .await
3514            .unwrap()
3515            .candidates;
3516        apply_sort_and_limit(&mut candidates, &config);
3517        let names: Vec<&str> = candidates
3518            .iter()
3519            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3520            .collect();
3521        assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3522    }
3523
3524    #[tokio::test]
3525    async fn test_shuffle_deterministic() {
3526        let dir = tempfile::tempdir().unwrap();
3527        let base = dir.path();
3528        fs::write(base.join("a.txt"), b"1").await.unwrap();
3529        fs::write(base.join("b.txt"), b"2").await.unwrap();
3530        fs::write(base.join("c.txt"), b"3").await.unwrap();
3531        fs::write(base.join("d.txt"), b"4").await.unwrap();
3532
3533        let config =
3534            FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3535        let filters = CompiledFilters::compile(&config).unwrap();
3536        let mut seen = HashSet::new();
3537        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3538            .await
3539            .unwrap()
3540            .candidates;
3541        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3542
3543        let mut candidates2 =
3544            crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3545                .await
3546                .unwrap()
3547                .candidates;
3548        crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3549
3550        let names1: Vec<&str> = candidates
3551            .iter()
3552            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3553            .collect();
3554        let names2: Vec<&str> = candidates2
3555            .iter()
3556            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3557            .collect();
3558        assert_eq!(names1, names2);
3559        assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3560    }
3561
3562    #[tokio::test]
3563    async fn test_non_eager_limit() {
3564        let dir = tempfile::tempdir().unwrap();
3565        let base = dir.path();
3566        fs::write(base.join("a.txt"), b"1").await.unwrap();
3567        fs::write(base.join("b.txt"), b"2").await.unwrap();
3568        fs::write(base.join("c.txt"), b"3").await.unwrap();
3569
3570        let config = FileConfig::from_uri(&format!(
3571            "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3572            base.display()
3573        ))
3574        .unwrap();
3575        let filters = CompiledFilters::compile(&config).unwrap();
3576        let mut seen = HashSet::new();
3577        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3578            .await
3579            .unwrap()
3580            .candidates;
3581        assert_eq!(candidates.len(), 3);
3582        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3583        assert_eq!(candidates.len(), 2);
3584    }
3585
3586    #[tokio::test]
3587    async fn test_poll_one_file_sets_all_headers() {
3588        let dir = tempfile::tempdir().unwrap();
3589        let base = dir.path();
3590        fs::write(base.join("test.txt"), b"hello world")
3591            .await
3592            .unwrap();
3593
3594        let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3595
3596        let file_path = base.join("test.txt");
3597        let include_re = None;
3598        let exclude_re = None;
3599        let mut seen = std::collections::HashSet::new();
3600        let in_process_locks = std::sync::Arc::new(DashMap::new());
3601        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3602
3603        let result = poll_one_file(
3604            &config,
3605            file_path.clone(),
3606            base,
3607            &include_re,
3608            &exclude_re,
3609            &mut seen,
3610            &in_process_locks,
3611            &idempotent_repo,
3612        )
3613        .await
3614        .unwrap();
3615
3616        let exchange = result.expect("poll_one_file should return an exchange");
3617
3618        let camel_file_name = exchange
3619            .input
3620            .header("CamelFileName")
3621            .and_then(|v| v.as_str().map(String::from))
3622            .expect("CamelFileName header");
3623        let camel_file_name_only = exchange
3624            .input
3625            .header("CamelFileNameOnly")
3626            .and_then(|v| v.as_str().map(String::from))
3627            .expect("CamelFileNameOnly header");
3628        let camel_file_absolute_path = exchange
3629            .input
3630            .header("CamelFileAbsolutePath")
3631            .and_then(|v| v.as_str().map(String::from))
3632            .expect("CamelFileAbsolutePath header");
3633        exchange
3634            .input
3635            .header("CamelFileLength")
3636            .and_then(|v| v.as_u64())
3637            .expect("CamelFileLength header");
3638        exchange
3639            .input
3640            .header("CamelFileLastModified")
3641            .and_then(|v| v.as_u64())
3642            .expect("CamelFileLastModified header");
3643        let camel_file_path = exchange
3644            .input
3645            .header("CamelFilePath")
3646            .and_then(|v| v.as_str().map(String::from))
3647            .expect("CamelFilePath header");
3648        let camel_file_parent = exchange
3649            .input
3650            .header("CamelFileParent")
3651            .and_then(|v| v.as_str().map(String::from))
3652            .expect("CamelFileParent header");
3653        let camel_file_canonical_path = exchange
3654            .input
3655            .header("CamelFileCanonicalPath")
3656            .and_then(|v| v.as_str().map(String::from))
3657            .expect("CamelFileCanonicalPath header");
3658        let camel_file_relative_path = exchange
3659            .input
3660            .header("CamelFileRelativePath")
3661            .and_then(|v| v.as_str().map(String::from))
3662            .expect("CamelFileRelativePath header");
3663
3664        assert_eq!(camel_file_name, "test.txt");
3665        assert_eq!(camel_file_name_only, "test.txt");
3666        assert_eq!(camel_file_relative_path, camel_file_name);
3667        assert_eq!(camel_file_path, base.to_string_lossy());
3668        assert_eq!(camel_file_parent, base.to_string_lossy());
3669        assert!(camel_file_absolute_path.ends_with("test.txt"));
3670        assert!(camel_file_canonical_path.ends_with("test.txt"));
3671    }
3672
3673    #[test]
3674    fn test_try_rename_requires_temp_prefix() {
3675        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3676        assert!(result.is_err());
3677        assert!(
3678            result
3679                .unwrap_err()
3680                .to_string()
3681                .contains("TryRename requires tempPrefix")
3682        );
3683    }
3684
3685    #[test]
3686    fn test_try_rename_with_temp_prefix_ok() {
3687        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3688        assert!(result.is_ok());
3689        assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3690    }
3691
3692    #[tokio::test]
3693    async fn test_try_rename_producer_writes_file() {
3694        use tower::ServiceExt;
3695
3696        let dir = tempfile::tempdir().unwrap();
3697        let base = dir.path();
3698
3699        let component = FileComponent::new();
3700        let ctx = NoOpComponentContext;
3701        let endpoint = component
3702            .create_endpoint(
3703                &format!(
3704                    "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3705                    base.display()
3706                ),
3707                &ctx,
3708            )
3709            .unwrap();
3710        let ctx = test_producer_ctx();
3711        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3712
3713        let mut exchange = Exchange::new(Message::new("hello"));
3714        exchange.input.set_header(
3715            "CamelFileName",
3716            serde_json::Value::String("output.txt".to_string()),
3717        );
3718
3719        producer.oneshot(exchange).await.unwrap();
3720
3721        let target = base.join("output.txt");
3722        assert!(target.exists(), "output file should exist");
3723        let content = std::fs::read_to_string(&target).unwrap();
3724        assert_eq!(content, "hello");
3725
3726        let tmp_files: Vec<_> = std::fs::read_dir(base)
3727            .unwrap()
3728            .filter_map(|e| e.ok())
3729            .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3730            .collect();
3731        assert!(tmp_files.is_empty(), "no temp files should remain");
3732    }
3733
3734    // -----------------------------------------------------------------------
3735    // Integration tests: scan_candidates + apply_sort_and_limit pipeline
3736    // -----------------------------------------------------------------------
3737
3738    #[tokio::test]
3739    async fn test_pipeline_sort_then_limit() {
3740        let dir = tempfile::tempdir().unwrap();
3741        let base = dir.path();
3742        fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3743        fs::write(base.join("a.txt"), b"a").await.unwrap();
3744        fs::write(base.join("b.txt"), b"bb").await.unwrap();
3745
3746        let config = FileConfig::from_uri(&format!(
3747            "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3748            base.display()
3749        ))
3750        .unwrap();
3751        let filters = CompiledFilters::compile(&config).unwrap();
3752        let mut seen = HashSet::new();
3753        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3754            .await
3755            .unwrap()
3756            .candidates;
3757        apply_sort_and_limit(&mut candidates, &config);
3758        assert_eq!(candidates.len(), 2);
3759        assert_eq!(
3760            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3761            "a.txt"
3762        );
3763        assert_eq!(
3764            candidates[1].path.file_name().unwrap().to_str().unwrap(),
3765            "b.txt"
3766        );
3767    }
3768
3769    #[tokio::test]
3770    async fn test_sort_reverse_ignore_case() {
3771        let dir = tempfile::tempdir().unwrap();
3772        let base = dir.path();
3773        fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3774        fs::write(base.join("beta.txt"), b"b").await.unwrap();
3775
3776        let config = FileConfig::from_uri(&format!(
3777            "file://{}?sortBy=reverse:ignoreCase:file:name",
3778            base.display()
3779        ))
3780        .unwrap();
3781        let filters = CompiledFilters::compile(&config).unwrap();
3782        let mut seen = HashSet::new();
3783        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3784            .await
3785            .unwrap()
3786            .candidates;
3787        apply_sort_and_limit(&mut candidates, &config);
3788        assert_eq!(
3789            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3790            "beta.txt"
3791        );
3792    }
3793
3794    #[tokio::test]
3795    async fn test_done_file_noop_no_deletion() {
3796        let dir = tempfile::tempdir().unwrap();
3797        let base = dir.path();
3798        fs::write(base.join("data.txt"), b"d").await.unwrap();
3799        fs::write(base.join("done"), b"").await.unwrap();
3800
3801        let config = FileConfig::from_uri(&format!(
3802            "file://{}?doneFileName=done&noop=true",
3803            base.display()
3804        ))
3805        .unwrap();
3806        let filters = CompiledFilters::compile(&config).unwrap();
3807        let mut seen = HashSet::new();
3808        let candidates = scan_candidates(&config, &filters, base, &mut seen)
3809            .await
3810            .unwrap()
3811            .candidates;
3812        assert_eq!(candidates.len(), 1);
3813        assert!(base.join("done").exists());
3814    }
3815
3816    #[tokio::test]
3817    async fn test_static_done_file_deleted_after_all_processed() {
3818        let dir = tempfile::tempdir().unwrap();
3819        let base = dir.path();
3820        fs::write(base.join("data.txt"), b"d").await.unwrap();
3821        fs::write(base.join("ready"), b"").await.unwrap();
3822
3823        let config = FileConfig::from_uri(&format!(
3824            "file://{}?doneFileName=ready&delete=true",
3825            base.display()
3826        ))
3827        .unwrap();
3828        let filters = CompiledFilters::compile(&config).unwrap();
3829        let mut seen = HashSet::new();
3830        let in_process_locks = std::sync::Arc::new(DashMap::new());
3831        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3832        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3833        let token = CancellationToken::new();
3834        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3835
3836        poll_directory(
3837            &config,
3838            &ctx,
3839            &filters,
3840            &mut seen,
3841            &in_process_locks,
3842            &idempotent_repo,
3843        )
3844        .await
3845        .unwrap();
3846
3847        assert!(rx.try_recv().is_ok());
3848        assert!(!base.join("ready").exists());
3849    }
3850
3851    #[tokio::test]
3852    async fn test_static_done_file_not_deleted_when_limited() {
3853        let dir = tempfile::tempdir().unwrap();
3854        let base = dir.path();
3855        fs::write(base.join("a.txt"), b"a").await.unwrap();
3856        fs::write(base.join("b.txt"), b"b").await.unwrap();
3857        fs::write(base.join("ready"), b"").await.unwrap();
3858
3859        let config = FileConfig::from_uri(&format!(
3860            "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
3861            base.display()
3862        ))
3863        .unwrap();
3864        let filters = CompiledFilters::compile(&config).unwrap();
3865        let mut seen = HashSet::new();
3866        let in_process_locks = std::sync::Arc::new(DashMap::new());
3867        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3868        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3869        let token = CancellationToken::new();
3870        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3871
3872        poll_directory(
3873            &config,
3874            &ctx,
3875            &filters,
3876            &mut seen,
3877            &in_process_locks,
3878            &idempotent_repo,
3879        )
3880        .await
3881        .unwrap();
3882
3883        let _ = rx.try_recv();
3884        assert!(base.join("ready").exists());
3885    }
3886
3887    #[tokio::test]
3888    async fn test_dynamic_done_file_deleted_per_file() {
3889        let dir = tempfile::tempdir().unwrap();
3890        let base = dir.path();
3891        fs::write(base.join("data.txt"), b"d").await.unwrap();
3892        fs::write(base.join("data.txt.done"), b"").await.unwrap();
3893
3894        let config = FileConfig::from_uri(&format!(
3895            "file://{}?doneFileName=${{file:name}}.done&delete=true",
3896            base.display()
3897        ))
3898        .unwrap();
3899        let filters = CompiledFilters::compile(&config).unwrap();
3900        let mut seen = HashSet::new();
3901        let in_process_locks = std::sync::Arc::new(DashMap::new());
3902        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3903        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3904        let token = CancellationToken::new();
3905        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3906
3907        poll_directory(
3908            &config,
3909            &ctx,
3910            &filters,
3911            &mut seen,
3912            &in_process_locks,
3913            &idempotent_repo,
3914        )
3915        .await
3916        .unwrap();
3917
3918        assert!(rx.try_recv().is_ok());
3919        assert!(!base.join("data.txt.done").exists());
3920    }
3921
3922    #[test]
3923    fn uri_options_count_parity() {
3924        assert_eq!(
3925            FileConfig::uri_options().len(),
3926            31,
3927            "FileUriConfig #[uri_param] count drifted from parser"
3928        );
3929    }
3930}