Skip to main content

mq_bridge/endpoints/
file.rs

1//  mq-bridge
2//  © Copyright 2025, by Marco Mengelkoch
3//  Licensed under MIT License, see License file for more details
4//  git clone https://github.com/marcomq/mq-bridge
5use crate::canonical_message::{deserialize_u128, tracing_support::LazyMessageIds};
6use crate::event_store::{EventStore, EventStoreConsumer, RetentionPolicy};
7use crate::models::{FileConfig, FileConsumerMode, FileFormat};
8use crate::traits::{
9    ConsumerError, MessageConsumer, MessagePublisher, PublisherError, ReceivedBatch, SentBatch,
10};
11use crate::CanonicalMessage;
12use anyhow::Context;
13use async_trait::async_trait;
14use once_cell::sync::Lazy;
15use std::any::Any;
16use std::collections::HashMap;
17use std::io::Seek;
18use std::path::Path;
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex as StdMutex, Weak};
21use tokio::fs::{self, File, OpenOptions};
22use tokio::io::{self, AsyncBufReadExt, AsyncSeekExt, BufReader};
23use tokio::io::{AsyncWriteExt, BufWriter};
24use tokio::sync::Mutex;
25use tracing::{info, instrument, trace, warn};
26
27/// A sink that writes messages to a file, one per line.
28static FILE_LOCKS: Lazy<StdMutex<HashMap<String, Arc<Mutex<()>>>>> =
29    Lazy::new(|| StdMutex::new(HashMap::new()));
30
31fn get_file_lock(path: &str) -> Arc<Mutex<()>> {
32    let mut locks = FILE_LOCKS.lock().unwrap();
33    locks.retain(|_, v| Arc::strong_count(v) > 1);
34    locks
35        .entry(path.to_string())
36        .or_insert_with(|| Arc::new(Mutex::new(())))
37        .clone()
38}
39
40fn parse_delimiter(delimiter: Option<&str>) -> anyhow::Result<Vec<u8>> {
41    let bytes = match delimiter {
42        Some(s) if s.starts_with("0x") => {
43            let hex = s.trim_start_matches("0x");
44            if hex.len() != 2 {
45                return Err(anyhow::anyhow!(
46                    "Hex delimiter must be 1 byte (2 hex chars)"
47                ));
48            }
49            (0..hex.len())
50                .step_by(2)
51                .map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
52                .collect::<Result<Vec<u8>, _>>()
53                .map_err(|e| anyhow::anyhow!("Invalid hex delimiter: {}", e))?
54        }
55        Some(s) => s.as_bytes().to_vec(),
56        None => vec![b'\n'],
57    };
58
59    if bytes.is_empty() {
60        return Err(anyhow::anyhow!("Delimiter cannot be empty"));
61    }
62
63    Ok(bytes)
64}
65
66async fn read_until_bytes<R: AsyncBufReadExt + Unpin>(
67    reader: &mut R,
68    delimiter: &[u8],
69    buf: &mut Vec<u8>,
70) -> std::io::Result<usize> {
71    if delimiter.len() == 1 {
72        return reader.read_until(delimiter[0], buf).await;
73    }
74    let last_byte = delimiter[delimiter.len() - 1];
75    let mut total_read = 0;
76    loop {
77        let n = reader.read_until(last_byte, buf).await?;
78        if n == 0 {
79            return Ok(total_read);
80        }
81        total_read += n;
82        if buf.len() >= delimiter.len() && &buf[buf.len() - delimiter.len()..] == delimiter {
83            return Ok(total_read);
84        }
85    }
86}
87
88#[derive(Clone)]
89pub struct FilePublisher {
90    path: String,
91    file_lock: Arc<Mutex<()>>,
92    delimiter: Vec<u8>,
93    format: FileFormat,
94}
95
96impl FilePublisher {
97    pub async fn new(config: &FileConfig) -> anyhow::Result<Self> {
98        let path_str = &config.path;
99        let path = Path::new(path_str);
100        if let Some(parent) = path.parent() {
101            tokio::fs::create_dir_all(parent).await.with_context(|| {
102                format!("Failed to create parent directory for file: {:?}", parent)
103            })?;
104        }
105
106        let _ = OpenOptions::new()
107            .create(true)
108            .append(true)
109            .open(&path)
110            .await
111            .with_context(|| format!("Failed to open or create file for writing: {}", path_str))?;
112
113        let file_lock = get_file_lock(path_str);
114        let delimiter = parse_delimiter(config.delimiter.as_deref())?;
115        let format = config.format.clone();
116
117        info!(path = %path_str, format = ?format, "File sink opened for appending");
118        Ok(Self {
119            path: path_str.to_string(),
120            file_lock,
121            delimiter,
122            format,
123        })
124    }
125}
126
127#[async_trait]
128impl MessagePublisher for FilePublisher {
129    #[instrument(skip_all, fields(batch_size = messages.len()), level = "debug")]
130    async fn send_batch(
131        &self,
132        messages: Vec<CanonicalMessage>,
133    ) -> Result<SentBatch, PublisherError> {
134        if messages.is_empty() {
135            return Ok(SentBatch::Ack);
136        }
137
138        trace!(count = messages.len(), path = %self.path, message_ids = ?LazyMessageIds(&messages), "Writing batch to file");
139        let _file_guard = self.file_lock.lock().await;
140
141        // We open the file for every batch to ensure we are writing to the current file path.
142        // This handles external file rotation/deletion (e.g. by the consumer in delete mode)
143        // where the old file handle would point to a deleted inode.
144        // While this has a performance cost, it ensures correctness.
145        let file = OpenOptions::new()
146            .create(true)
147            .append(true)
148            .open(&self.path)
149            .await
150            .context("Failed to open file for writing batch")?;
151
152        let mut writer = BufWriter::new(file);
153        let mut failed_messages = Vec::new();
154
155        // Iterate over messages, consuming them
156        for msg in messages {
157            let serialized_msg = match self.format {
158                FileFormat::Raw => Ok(msg.payload.to_vec()),
159                FileFormat::Normal => {
160                    if msg
161                        .metadata
162                        .get("mq_bridge.original_format")
163                        .map(|s| s.as_str())
164                        == Some("raw")
165                    {
166                        // If the message was originally raw, pass its payload through directly
167                        // to support raw file-to-file copies without re-wrapping.
168                        Ok(msg.payload.to_vec())
169                    } else {
170                        serde_json::to_vec(&msg)
171                    }
172                }
173                FileFormat::Json => {
174                    if let Ok(json_val) = serde_json::from_slice::<serde_json::Value>(&msg.payload)
175                    {
176                        #[derive(serde::Serialize)]
177                        struct JsonWrapper<'a> {
178                            #[serde(serialize_with = "crate::canonical_message::print_uuidv7")]
179                            message_id: u128,
180                            payload: serde_json::Value,
181                            metadata: &'a HashMap<String, String>,
182                        }
183                        serde_json::to_vec(&JsonWrapper {
184                            message_id: msg.message_id,
185                            payload: json_val,
186                            metadata: &msg.metadata,
187                        })
188                    } else {
189                        serde_json::to_vec(&msg)
190                    }
191                }
192                FileFormat::Text => {
193                    if let Ok(text) = std::str::from_utf8(&msg.payload) {
194                        #[derive(serde::Serialize)]
195                        struct TextWrapper<'a> {
196                            #[serde(serialize_with = "crate::canonical_message::print_uuidv7")]
197                            message_id: u128,
198                            payload: &'a str,
199                            metadata: &'a HashMap<String, String>,
200                        }
201                        serde_json::to_vec(&TextWrapper {
202                            message_id: msg.message_id,
203                            payload: text,
204                            metadata: &msg.metadata,
205                        })
206                    } else {
207                        serde_json::to_vec(&msg)
208                    }
209                }
210            };
211            let serialized_msg = match serialized_msg {
212                Ok(s) => s,
213                Err(e) => {
214                    tracing::error!("Failed to serialize message for file sink: {}", e);
215                    failed_messages.push((msg, PublisherError::NonRetryable(anyhow::anyhow!(e))));
216                    continue;
217                }
218            };
219
220            if let Err(e) = writer.write_all(&serialized_msg).await {
221                tracing::error!("Failed to write message to file: {}", e);
222                failed_messages.push((msg, PublisherError::NonRetryable(anyhow::anyhow!(e))));
223            } else if let Err(e) = writer.write_all(&self.delimiter).await {
224                tracing::error!("Failed to write delimiter to file: {}", e);
225                return Err(PublisherError::NonRetryable(anyhow::anyhow!(e)));
226            }
227        }
228
229        writer
230            .flush()
231            .await
232            .context("Failed to flush file writer")?;
233        if failed_messages.is_empty() {
234            Ok(SentBatch::Ack)
235        } else {
236            Ok(SentBatch::Partial {
237                responses: None,
238                failed: failed_messages,
239            })
240        }
241    }
242
243    async fn flush(&self) -> anyhow::Result<()> {
244        Ok(())
245    }
246
247    fn as_any(&self) -> &dyn Any {
248        self
249    }
250}
251
252static FILE_EVENT_STORES: Lazy<Mutex<HashMap<String, Weak<EventStore>>>> =
253    Lazy::new(|| Mutex::new(HashMap::new()));
254
255struct FileFeedState {
256    /// For Consume mode: number of lines currently buffered in EventStore.
257    lines_in_memory: usize,
258}
259
260/// Creates an EventStore backed by a file.
261/// The EventStore acts as an in-memory buffer for the file content, allowing unified handling of Consume and Subscribe modes.
262async fn create_file_event_store(
263    path: &str,
264    delimiter: Vec<u8>,
265    format: FileFormat,
266) -> anyhow::Result<Arc<EventStore>> {
267    let path = path.to_string();
268    // Shared state to coordinate the reader and the drop (delete) logic.
269    let feed_state = Arc::new(Mutex::new(FileFeedState { lines_in_memory: 0 }));
270
271    // Lock to serialize file modification operations
272    let file_op_lock = get_file_lock(&path);
273
274    let feed_state_clone = feed_state.clone();
275    let path_clone = path.clone();
276    let file_op_lock_clone = file_op_lock.clone();
277    let delimiter_clone = delimiter.clone();
278
279    let retention = RetentionPolicy {
280        gc_interval: std::time::Duration::ZERO,
281        ..Default::default()
282    };
283    // Use immediate GC for file stores to ensure files are truncated promptly on ack.
284
285    // 1. Create EventStore with on_drop callback
286    let store = Arc::new(
287        EventStore::new(retention).with_drop_callback(move |events| {
288            // In EventStore mode (Subscribe + Delete), we always delete.
289            let count = events.len();
290            if count == 0 {
291                return;
292            }
293            let state = feed_state_clone.clone();
294            let path = path_clone.clone();
295            let file_op_lock = file_op_lock_clone.clone();
296            let delimiter = delimiter_clone.clone();
297
298            tokio::spawn(async move {
299                // Serialize file operations to prevent race conditions between multiple GCs
300                let _guard = file_op_lock.lock().await;
301
302                {
303                    let mut s = state.lock().await;
304                    s.lines_in_memory = s.lines_in_memory.saturating_sub(count);
305                }
306
307                if let Err(e) = remove_lines_from_file(&path, count, &delimiter).await {
308                    tracing::error!("Failed to remove lines from file {}: {}", path, e);
309                    // Note: In this simplified model, if deletion fails, lines_in_memory
310                    // might become out of sync, leading to reprocessing on restart.
311                } else {
312                    trace!("Removed {} lines from {}", count, path);
313                }
314            });
315        }),
316    );
317
318    // 2. Spawn background reader task
319    let store_weak = Arc::downgrade(&store);
320    let path_clone = path.clone();
321    let feed_state_clone = feed_state.clone();
322    let file_op_lock_clone = file_op_lock.clone();
323    let format_clone = format;
324
325    tokio::spawn(async move {
326        let mut current_sleep = std::time::Duration::from_millis(1);
327        const MAX_SLEEP: std::time::Duration = std::time::Duration::from_millis(100);
328
329        loop {
330            // Check if the store is still alive
331            let store_clone = match store_weak.upgrade() {
332                Some(s) => s,
333                None => break, // Exit if EventStore is dropped
334            };
335
336            // Acquire file op lock first to coordinate with GC
337            let file_guard = Some(file_op_lock_clone.lock().await);
338
339            let mut state = feed_state_clone.lock().await;
340
341            // Open file
342            let file_res = OpenOptions::new().read(true).open(&path_clone).await;
343            let mut file = match file_res {
344                Ok(f) => f,
345                Err(e) => {
346                    tracing::error!("Failed to open file {}: {}", path_clone, e);
347                    drop(state);
348                    drop(file_guard);
349                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
350                    continue;
351                }
352            };
353
354            // Position the reader
355            // In consume mode, we skip lines that are already buffered in memory
356            // because they are still in the file (until dropped).
357            let mut reader = BufReader::new(file);
358            let mut lines_skipped = 0;
359            let mut error = false;
360            let lines_to_skip = state.lines_in_memory;
361            while lines_skipped < lines_to_skip {
362                let mut buf = Vec::new();
363                match read_until_bytes(&mut reader, &delimiter, &mut buf).await {
364                    Ok(0) => break, // EOF
365                    Ok(_) => lines_skipped += 1,
366                    Err(e) => {
367                        tracing::error!("Error skipping lines in {}: {}", path_clone, e);
368                        error = true;
369                        break;
370                    }
371                }
372            }
373            if error {
374                drop(state);
375                drop(file_guard);
376                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
377                continue;
378            }
379            file = reader.into_inner();
380
381            // Release file op lock to allow publisher to write while we read
382            drop(file_guard);
383
384            // Read new lines
385            let mut reader = BufReader::new(file);
386            let mut lines_read = 0;
387            let mut batch = Vec::with_capacity(128);
388
389            loop {
390                let mut buffer = Vec::new();
391                match read_until_bytes(&mut reader, &delimiter, &mut buffer).await {
392                    Ok(0) => break,
393                    Ok(_) => {
394                        if buffer.ends_with(&delimiter) {
395                            buffer.truncate(buffer.len() - delimiter.len());
396                        }
397                        if delimiter.len() == 1 && delimiter[0] == b'\n' && buffer.ends_with(b"\r")
398                        {
399                            buffer.pop();
400                        }
401                        let msg = parse_message(&buffer, &format_clone);
402                        batch.push(msg);
403                        lines_read += 1;
404
405                        state.lines_in_memory += 1;
406
407                        if batch.len() >= 128 {
408                            store_clone.append_batch(std::mem::take(&mut batch)).await;
409                            batch.reserve(128);
410                        }
411                    }
412                    Err(e) => {
413                        tracing::error!("Error reading from {}: {}", path_clone, e);
414                        break;
415                    }
416                }
417            }
418
419            if !batch.is_empty() {
420                store_clone.append_batch(batch).await;
421            }
422
423            drop(state); // Release lock before sleeping
424
425            // If we didn't read anything, sleep a bit (polling)
426            if lines_read == 0 {
427                tokio::time::sleep(current_sleep).await;
428                current_sleep = std::cmp::min(current_sleep * 2, MAX_SLEEP);
429            } else {
430                current_sleep = std::time::Duration::from_millis(1);
431            }
432        }
433    });
434
435    Ok(store)
436}
437
438async fn remove_lines_from_file(path: &str, count: usize, delimiter: &[u8]) -> anyhow::Result<()> {
439    let unique_id = fast_uuid_v7::gen_id_str();
440    let temp_path = format!("{}.{}.tmp", path, unique_id);
441
442    let file = File::open(path).await?;
443    let mut reader = BufReader::new(file);
444    let temp_file = File::create(&temp_path).await?;
445    let mut writer = BufWriter::new(temp_file);
446
447    let mut lines_skipped = 0;
448    while lines_skipped < count {
449        let mut buf = Vec::new();
450        if read_until_bytes(&mut reader, delimiter, &mut buf).await? == 0 {
451            break;
452        }
453        lines_skipped += 1;
454    }
455
456    if let Err(e) = io::copy(&mut reader, &mut writer).await {
457        let _ = fs::remove_file(&temp_path).await;
458        return Err(e.into());
459    }
460
461    writer.flush().await?;
462    let temp_file = writer.into_inner();
463    temp_file.sync_all().await?;
464    drop(temp_file); // Close writer handle
465    drop(reader); // Close reader handle
466
467    fs::rename(&temp_path, path).await?;
468
469    // Sync parent directory to ensure rename is durable
470    if let Some(parent) = Path::new(path).parent() {
471        if let Ok(parent_dir) = File::open(parent).await {
472            let _ = parent_dir.sync_all().await;
473        }
474    }
475
476    Ok(())
477}
478
479struct FileTailConsumer {
480    msg_rx: async_channel::Receiver<Vec<CanonicalMessage>>,
481    buffer: Vec<CanonicalMessage>,
482    offset_file: Option<Arc<Mutex<tokio::fs::File>>>,
483    ready: Arc<AtomicBool>,
484}
485
486fn read_until_bytes_sync<R: std::io::BufRead>(
487    reader: &mut R,
488    delimiter: &[u8],
489    buf: &mut Vec<u8>,
490) -> std::io::Result<usize> {
491    if delimiter.len() == 1 {
492        return reader.read_until(delimiter[0], buf);
493    }
494    let last_byte = delimiter[delimiter.len() - 1];
495    let mut total_read = 0;
496    loop {
497        let n = reader.read_until(last_byte, buf)?;
498        if n == 0 {
499            return Ok(total_read);
500        }
501        total_read += n;
502        if buf.len() >= delimiter.len() && &buf[buf.len() - delimiter.len()..] == delimiter {
503            return Ok(total_read);
504        }
505    }
506}
507
508fn run_file_tail_task_sync(
509    path: String,
510    msg_tx: async_channel::Sender<Vec<CanonicalMessage>>,
511    initial_offset: u64,
512    group_id: Option<String>,
513    delimiter: Vec<u8>,
514    format: FileFormat,
515    ready: Arc<AtomicBool>,
516) {
517    let mut last_position: u64 = initial_offset;
518    let mut reader: Option<std::io::BufReader<std::fs::File>> = None;
519    let mut current_sleep = std::time::Duration::from_millis(1);
520    const MAX_SLEEP: std::time::Duration = std::time::Duration::from_millis(50);
521    let mut initialized = false;
522    const BATCH_SIZE: usize = 1024;
523    let mut buf = Vec::with_capacity(1024);
524
525    loop {
526        if reader.is_none() {
527            let mut file = match std::fs::OpenOptions::new().read(true).open(&path) {
528                Ok(f) => f,
529                Err(e) => {
530                    tracing::error!("Failed to open {}: {}", path, e);
531                    std::thread::sleep(std::time::Duration::from_secs(1));
532                    continue;
533                }
534            };
535
536            if let Ok(metadata) = file.metadata() {
537                if metadata.len() < last_position {
538                    tracing::warn!("File {} was truncated. Resetting position to 0.", path);
539                    last_position = 0;
540                }
541            }
542
543            if let Err(e) = file.seek(std::io::SeekFrom::Start(last_position)) {
544                tracing::error!("Failed to seek in {}: {}", path, e);
545                last_position = 0; // Reset on seek failure
546                if let Err(e) = file.seek(std::io::SeekFrom::Start(0)) {
547                    tracing::error!("Failed to reset seek to 0 in {}: {}", path, e);
548                    std::thread::sleep(std::time::Duration::from_secs(1));
549                    continue;
550                }
551            }
552
553            reader = Some(std::io::BufReader::with_capacity(128 * BATCH_SIZE, file));
554            if !initialized {
555                ready.store(true, Ordering::SeqCst);
556                initialized = true;
557            }
558        }
559
560        let mut batch = Vec::with_capacity(BATCH_SIZE);
561        let mut lines_read_in_batch = 0;
562
563        if let Some(r) = reader.as_mut() {
564            for _ in 0..BATCH_SIZE {
565                buf.clear();
566                match read_until_bytes_sync(r, &delimiter, &mut buf) {
567                    Ok(0) => break, // EOF
568                    Ok(n) => {
569                        last_position += n as u64;
570                        if buf.ends_with(&delimiter) {
571                            buf.truncate(buf.len() - delimiter.len());
572                        }
573                        if delimiter.len() == 1 && delimiter[0] == b'\n' && buf.ends_with(b"\r") {
574                            buf.pop();
575                        }
576                        let mut msg = parse_message(&buf, &format);
577                        if group_id.is_some() {
578                            msg.metadata
579                                .insert("file_offset".to_string(), last_position.to_string());
580                        }
581                        batch.push(msg);
582                        lines_read_in_batch += 1;
583                    }
584                    Err(e) => {
585                        tracing::error!("Error reading {}: {}", path, e);
586                        reader = None; // Force reopen on next loop
587                        break;
588                    }
589                }
590            }
591        }
592
593        if !batch.is_empty() {
594            if msg_tx.send_blocking(batch).is_err() {
595                break; // Consumer dropped, exit thread
596            }
597            current_sleep = std::time::Duration::from_millis(1);
598        }
599
600        if lines_read_in_batch == 0 {
601            // EOF reached, sleep
602            std::thread::sleep(current_sleep);
603            current_sleep = std::cmp::min(current_sleep * 2, MAX_SLEEP);
604            // Invalidate reader to check for file changes (like rotation) on next poll
605            reader = None;
606        }
607    }
608}
609
610struct FileQueueConsumer {
611    msg_rx: async_channel::Receiver<Vec<CanonicalMessage>>,
612    lines_in_memory: Arc<AtomicUsize>,
613    path: String,
614    file_lock: Arc<Mutex<()>>,
615    buffer: Arc<Mutex<Vec<CanonicalMessage>>>,
616    delimiter: Vec<u8>,
617    ready: Arc<AtomicBool>,
618}
619
620#[allow(clippy::too_many_arguments)]
621fn run_file_queue_task(
622    path: String,
623    msg_tx: async_channel::Sender<Vec<CanonicalMessage>>,
624    lines_in_memory: Arc<AtomicUsize>,
625    file_lock: Arc<Mutex<()>>,
626    runtime_handle: tokio::runtime::Handle,
627    delimiter: Vec<u8>,
628    format: FileFormat,
629    ready: Arc<AtomicBool>,
630) {
631    let mut current_sleep = std::time::Duration::from_millis(1);
632    const MAX_SLEEP: std::time::Duration = std::time::Duration::from_millis(100);
633    let mut initialized = false;
634    let mut buf = Vec::new();
635
636    loop {
637        buf.clear();
638        let mut batch = Vec::with_capacity(128);
639        let mut lines_read = 0;
640
641        {
642            let _guard = runtime_handle.block_on(file_lock.lock());
643            let skip_count = lines_in_memory.load(Ordering::SeqCst);
644
645            let file = match std::fs::OpenOptions::new().read(true).open(&path) {
646                Ok(f) => f,
647                Err(e) => {
648                    tracing::error!("Failed to open {}: {}", path, e);
649                    drop(_guard);
650                    std::thread::sleep(std::time::Duration::from_secs(1));
651                    continue;
652                }
653            };
654
655            let mut reader = std::io::BufReader::new(file);
656            let mut skipped = 0;
657            let mut error = false;
658
659            while skipped < skip_count {
660                buf.clear();
661                match read_until_bytes_sync(&mut reader, &delimiter, &mut buf) {
662                    Ok(0) => break,
663                    Ok(_) => skipped += 1,
664                    Err(e) => {
665                        tracing::error!("Error skipping lines in {}: {}", path, e);
666                        error = true;
667                        break;
668                    }
669                }
670            }
671
672            if !error {
673                for _ in 0..128 {
674                    buf.clear();
675                    match read_until_bytes_sync(&mut reader, &delimiter, &mut buf) {
676                        Ok(0) => break,
677                        Ok(_) => {
678                            if buf.ends_with(&delimiter) {
679                                buf.truncate(buf.len() - delimiter.len());
680                            }
681                            if delimiter.len() == 1 && delimiter[0] == b'\n' && buf.ends_with(b"\r")
682                            {
683                                buf.pop();
684                            }
685                            batch.push(parse_message(&buf, &format));
686                            lines_read += 1;
687                        }
688                        Err(_) => break,
689                    }
690                }
691
692                if !initialized {
693                    ready.store(true, Ordering::SeqCst);
694                    initialized = true;
695                }
696            }
697        }
698
699        if lines_read > 0 {
700            lines_in_memory.fetch_add(lines_read, Ordering::SeqCst);
701            if msg_tx.send_blocking(batch).is_err() {
702                break;
703            }
704            current_sleep = std::time::Duration::from_millis(1);
705        } else {
706            std::thread::sleep(current_sleep);
707            current_sleep = std::cmp::min(current_sleep * 2, MAX_SLEEP);
708        }
709    }
710}
711
712enum ConsumerBackend {
713    EventStore(EventStoreConsumer),
714    Tail(FileTailConsumer),
715    Queue(FileQueueConsumer),
716}
717
718/// A consumer that reads messages from a file and removes them upon commit.
719pub struct FileConsumer {
720    backend: ConsumerBackend,
721}
722
723impl FileConsumer {
724    pub async fn new(config: &FileConfig) -> anyhow::Result<Self> {
725        let delimiter = parse_delimiter(config.delimiter.as_deref())?;
726        let format = config.format.clone();
727        match &config.mode {
728            None | Some(FileConsumerMode::Consume { delete: false }) => {
729                Self::new_tail(&config.path, false, None, delimiter.clone(), format).await
730            }
731            Some(FileConsumerMode::Subscribe { delete: false }) => {
732                Self::new_tail(&config.path, true, None, delimiter.clone(), format).await
733            }
734            Some(FileConsumerMode::GroupSubscribe {
735                group_id,
736                read_from_tail,
737            }) => {
738                let start_at_end = *read_from_tail;
739                Self::new_tail(
740                    &config.path,
741                    start_at_end,
742                    Some(group_id.clone()),
743                    delimiter.clone(),
744                    format,
745                )
746                .await
747            }
748            Some(FileConsumerMode::Consume { delete: true }) => {
749                let (msg_tx, msg_rx) = async_channel::bounded(100);
750                let file_lock = get_file_lock(&config.path);
751                let lines_in_memory = Arc::new(AtomicUsize::new(0));
752                let ready = Arc::new(AtomicBool::new(false));
753                let ready_clone = ready.clone();
754                let lines_clone = lines_in_memory.clone();
755                let lock_clone = file_lock.clone();
756                let runtime = tokio::runtime::Handle::current();
757                let path_clone = config.path.clone();
758
759                let delimiter_clone = delimiter.clone();
760                let format_clone = format.clone();
761                std::thread::spawn(move || {
762                    run_file_queue_task(
763                        path_clone,
764                        msg_tx,
765                        lines_clone,
766                        lock_clone,
767                        runtime,
768                        delimiter_clone,
769                        format_clone,
770                        ready_clone,
771                    );
772                });
773
774                info!(path = %config.path, mode = "queue (delete, optimized)", "File consumer connected");
775                Ok(Self {
776                    backend: ConsumerBackend::Queue(FileQueueConsumer {
777                        msg_rx,
778                        lines_in_memory,
779                        path: config.path.clone(),
780                        file_lock,
781                        buffer: Arc::new(Mutex::new(Vec::new())),
782                        delimiter,
783                        ready,
784                    }),
785                })
786            }
787            Some(FileConsumerMode::Subscribe { delete: true }) => {
788                let key = format!(
789                    "{}|subscribe|delete|{:?}|{:?}",
790                    config.path, format, delimiter
791                );
792
793                let store = if let Some(store) = {
794                    let mut stores = FILE_EVENT_STORES.lock().await;
795                    stores.retain(|_, v| v.strong_count() > 0);
796                    stores.get(&key).and_then(|w| w.upgrade())
797                } {
798                    store
799                } else {
800                    let created =
801                        create_file_event_store(&config.path, delimiter.clone(), format).await?;
802                    let mut stores = FILE_EVENT_STORES.lock().await;
803                    let store = stores
804                        .get(&key)
805                        .and_then(|w| w.upgrade())
806                        .unwrap_or_else(|| {
807                            stores.insert(key.clone(), Arc::downgrade(&created));
808                            created
809                        });
810                    store
811                };
812
813                let subscriber_id = format!("file-sub-{}", fast_uuid_v7::gen_id_str());
814                info!(path = %config.path, mode = "subscribe (delete)", subscriber_id = %subscriber_id, "File consumer connected");
815
816                Ok(Self {
817                    backend: ConsumerBackend::EventStore(store.consumer(subscriber_id)),
818                })
819            }
820        }
821    }
822
823    async fn new_tail(
824        path: &str,
825        start_at_end: bool,
826        group_id: Option<String>,
827        delimiter: Vec<u8>,
828        format: FileFormat,
829    ) -> anyhow::Result<Self> {
830        let (msg_tx, msg_rx) = async_channel::bounded(100);
831        let mut initial_offset = 0;
832        let ready = Arc::new(AtomicBool::new(false));
833        let ready_clone = ready.clone();
834        let mut offset_file = None;
835
836        if let Some(gid) = &group_id {
837            let offset_path = format!("{}.{}.offset", path, gid);
838            if let Ok(content) = tokio::fs::read_to_string(&offset_path).await {
839                if let Ok(pos) = content.trim().parse::<u64>() {
840                    initial_offset = pos;
841                    info!(
842                        "Restored offset {} for group {} from {}",
843                        pos, gid, offset_path
844                    );
845                }
846            }
847            let file = OpenOptions::new()
848                .write(true)
849                .create(true)
850                .truncate(false)
851                .open(&offset_path)
852                .await?;
853            offset_file = Some(Arc::new(Mutex::new(file)));
854        }
855
856        if initial_offset == 0 && start_at_end {
857            if let Ok(metadata) = tokio::fs::metadata(path).await {
858                initial_offset = metadata.len();
859            }
860        }
861
862        let path_clone = path.to_string();
863        let format_clone = format;
864        std::thread::spawn(move || {
865            run_file_tail_task_sync(
866                path_clone,
867                msg_tx,
868                initial_offset,
869                group_id,
870                delimiter,
871                format_clone,
872                ready_clone,
873            );
874        });
875
876        info!(path = %path, mode = "tail (no-delete, optimized)", "File consumer connected");
877
878        Ok(Self {
879            backend: ConsumerBackend::Tail(FileTailConsumer {
880                msg_rx,
881                buffer: Vec::new(),
882                offset_file,
883                ready,
884            }),
885        })
886    }
887
888    /// Returns true if the consumer is ready to receive messages.
889    pub fn is_ready(&self) -> bool {
890        match &self.backend {
891            ConsumerBackend::EventStore(_) => true,
892            ConsumerBackend::Tail(c) => c.ready.load(Ordering::SeqCst),
893            ConsumerBackend::Queue(c) => c.ready.load(Ordering::SeqCst),
894        }
895    }
896}
897
898#[async_trait]
899impl MessageConsumer for FileConsumer {
900    async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
901        match &mut self.backend {
902            ConsumerBackend::EventStore(c) => c.receive_batch(max_messages).await,
903            ConsumerBackend::Tail(c) => {
904                if c.buffer.is_empty() {
905                    match c.msg_rx.recv().await {
906                        Ok(batch) => c.buffer = batch,
907                        Err(_) => return Err(ConsumerError::EndOfStream),
908                    }
909                }
910
911                // Greedily fill buffer from channel if more messages are available
912                while c.buffer.len() < max_messages {
913                    match c.msg_rx.try_recv() {
914                        Ok(mut next_batch) => c.buffer.append(&mut next_batch),
915                        Err(_) => break, // Channel is empty or disconnected
916                    }
917                }
918
919                let count = std::cmp::min(c.buffer.len(), max_messages);
920                let messages: Vec<_> = c.buffer.drain(0..count).collect();
921
922                let commit: crate::traits::BatchCommitFunc = if let Some(offset_file) =
923                    &c.offset_file
924                {
925                    let offset_file = offset_file.clone();
926                    let captured_messages = messages.clone();
927
928                    Box::new(
929                        move |dispositions: Vec<crate::traits::MessageDisposition>| {
930                            Box::pin(async move {
931                                let max_offset = dispositions
932                                    .iter()
933                                    .zip(captured_messages.iter())
934                                    .filter_map(|(d, m)| match d {
935                                        crate::traits::MessageDisposition::Ack
936                                        | crate::traits::MessageDisposition::Reply(_) => m
937                                            .metadata
938                                            .get("file_offset")
939                                            .and_then(|s| s.parse::<u64>().ok()),
940                                        _ => None,
941                                    })
942                                    .max();
943
944                                if let Some(offset) = max_offset {
945                                    let mut file = offset_file.lock().await;
946                                    if let Err(e) = file.rewind().await {
947                                        tracing::error!("Failed to rewind offset file: {}", e);
948                                    } else if let Err(e) = file.set_len(0).await {
949                                        tracing::error!("Failed to truncate offset file: {}", e);
950                                    } else if let Err(e) =
951                                        file.write_all(offset.to_string().as_bytes()).await
952                                    {
953                                        tracing::error!("Failed to write offset file: {}", e);
954                                    } else if let Err(e) = file.flush().await {
955                                        tracing::error!("Failed to flush offset file: {}", e);
956                                    }
957                                }
958                                Ok(())
959                            })
960                                as crate::traits::BoxFuture<'static, anyhow::Result<()>>
961                        },
962                    )
963                } else {
964                    // No-op commit since we are not deleting and no group_id to track
965                    Box::new(|_dispositions: Vec<crate::traits::MessageDisposition>| {
966                        Box::pin(async move { Ok(()) })
967                            as crate::traits::BoxFuture<'static, anyhow::Result<()>>
968                    })
969                };
970
971                Ok(ReceivedBatch { messages, commit })
972            }
973            ConsumerBackend::Queue(c) => {
974                {
975                    let buffer = c.buffer.lock().await;
976                    if buffer.is_empty() {
977                        drop(buffer);
978                        match c.msg_rx.recv().await {
979                            Ok(b) => c.buffer.lock().await.extend(b),
980                            Err(_) => return Err(ConsumerError::EndOfStream),
981                        }
982                    }
983                }
984                let mut buffer = c.buffer.lock().await;
985
986                while buffer.len() < max_messages {
987                    match c.msg_rx.try_recv() {
988                        Ok(mut b) => buffer.append(&mut b),
989                        Err(_) => break,
990                    }
991                }
992
993                let count = std::cmp::min(buffer.len(), max_messages);
994                let batch: Vec<_> = buffer.drain(0..count).collect();
995                drop(buffer);
996
997                let path = c.path.clone();
998                let lock = c.file_lock.clone();
999                let buffer_clone = c.buffer.clone();
1000                let lines_mem = c.lines_in_memory.clone();
1001                let batch_for_commit = batch.clone();
1002                let delimiter = c.delimiter.clone();
1003
1004                let commit = Box::new(
1005                    move |dispositions: Vec<crate::traits::MessageDisposition>| {
1006                        Box::pin(async move {
1007                            let mut leading_acks = 0;
1008                            let mut nacked_msgs = Vec::new();
1009                            let mut encountered_nack = false;
1010
1011                            for (i, d) in dispositions.iter().enumerate() {
1012                                if encountered_nack {
1013                                    if let Some(msg) = batch_for_commit.get(i) {
1014                                        nacked_msgs.push(msg.clone());
1015                                    }
1016                                    continue;
1017                                }
1018                                match d {
1019                                    crate::traits::MessageDisposition::Ack
1020                                    | crate::traits::MessageDisposition::Reply(_) => {
1021                                        leading_acks += 1;
1022                                    }
1023                                    crate::traits::MessageDisposition::Nack => {
1024                                        encountered_nack = true;
1025                                        if let Some(msg) = batch_for_commit.get(i) {
1026                                            nacked_msgs.push(msg.clone());
1027                                        }
1028                                    }
1029                                }
1030                            }
1031
1032                            if !nacked_msgs.is_empty() {
1033                                let mut buf = buffer_clone.lock().await;
1034                                let old_buf = std::mem::take(&mut *buf);
1035                                let mut new_buf = nacked_msgs;
1036                                new_buf.extend(old_buf);
1037                                *buf = new_buf;
1038                            }
1039
1040                            if leading_acks > 0 {
1041                                let _guard = lock.lock().await;
1042                                if let Err(e) =
1043                                    remove_lines_from_file(&path, leading_acks, &delimiter).await
1044                                {
1045                                    tracing::error!("Failed to remove lines from {}: {}", path, e);
1046                                }
1047                                lines_mem.fetch_sub(leading_acks, Ordering::SeqCst);
1048                            }
1049                            Ok(())
1050                        })
1051                            as crate::traits::BoxFuture<'static, anyhow::Result<()>>
1052                    },
1053                );
1054
1055                Ok(ReceivedBatch {
1056                    messages: batch,
1057                    commit,
1058                })
1059            }
1060        }
1061    }
1062
1063    fn as_any(&self) -> &dyn Any {
1064        self
1065    }
1066}
1067
1068fn parse_message(buffer: &[u8], format: &FileFormat) -> CanonicalMessage {
1069    match format {
1070        FileFormat::Raw => {
1071            let mut msg = CanonicalMessage::new(buffer.to_vec(), None);
1072            msg.metadata
1073                .insert("mq_bridge.original_format".to_string(), "raw".to_string());
1074            msg
1075        }
1076        FileFormat::Normal | FileFormat::Json | FileFormat::Text => {
1077            #[derive(serde::Deserialize)]
1078            struct AnyPayloadMessage {
1079                #[serde(deserialize_with = "deserialize_u128")]
1080                message_id: u128,
1081                payload: serde_json::Value,
1082                #[serde(default)]
1083                metadata: HashMap<String, String>,
1084            }
1085
1086            match serde_json::from_slice::<AnyPayloadMessage>(buffer) {
1087                Ok(wrapper) => {
1088                    let payload_bytes = if matches!(format, FileFormat::Json) {
1089                        serde_json::to_vec(&wrapper.payload).unwrap_or_default()
1090                    } else {
1091                        match wrapper.payload {
1092                            serde_json::Value::String(s) => s.into_bytes(),
1093                            serde_json::Value::Array(arr) => {
1094                                if let Ok(bytes) = serde_json::from_value::<Vec<u8>>(
1095                                    serde_json::Value::Array(arr.clone()),
1096                                ) {
1097                                    bytes
1098                                } else {
1099                                    serde_json::to_vec(&serde_json::Value::Array(arr))
1100                                        .unwrap_or_default()
1101                                }
1102                            }
1103                            other => serde_json::to_vec(&other).unwrap_or_default(),
1104                        }
1105                    };
1106                    CanonicalMessage {
1107                        message_id: wrapper.message_id,
1108                        payload: payload_bytes.into(),
1109                        metadata: wrapper.metadata,
1110                    }
1111                }
1112                Err(e) => {
1113                    warn!(error = %e, content_length = buffer.len(), "Failed to parse file line as JSON, treating as raw.");
1114                    let mut msg = CanonicalMessage::new(buffer.to_vec(), None);
1115                    msg.metadata
1116                        .insert("mq_bridge.original_format".to_string(), "raw".to_string());
1117                    msg
1118                }
1119            }
1120        }
1121    }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use crate::endpoints::file::{FileConsumer, FilePublisher};
1127    use crate::models::{FileConfig, FileConsumerMode, FileFormat};
1128    use crate::msg;
1129    use crate::traits::MessageConsumer;
1130    use crate::traits::MessagePublisher;
1131    use serde_json::json;
1132    use tempfile::tempdir;
1133    use tokio::fs::OpenOptions;
1134    use tokio::io::AsyncWriteExt;
1135
1136    #[tokio::test]
1137    async fn test_file_sink_and_source_integration() {
1138        // 1. Setup a temporary directory and file path
1139        let dir = tempdir().unwrap();
1140        let file_path = dir.path().join("test.log");
1141        let file_path_str = file_path.to_str().unwrap().to_string();
1142
1143        // 2. Create a FileSink
1144        let config = FileConfig {
1145            path: file_path_str.clone(),
1146            ..Default::default()
1147        };
1148        let sink = FilePublisher::new(&config).await.unwrap();
1149
1150        let msg1 = msg!(json!({"hello": "world"}));
1151        let msg2 = msg!(json!({"foo": "bar"}));
1152
1153        sink.send_batch(vec![msg1.clone(), msg2.clone()])
1154            .await
1155            .unwrap();
1156        // Explicitly flush to ensure data is written before we try to read it.
1157        sink.flush().await.unwrap();
1158        // Drop the sink to release the file lock on some OSes before the source tries to open it.
1159        drop(sink);
1160
1161        // 4. Create a FileConsumer to read from the same file
1162        let mut source = FileConsumer::new(&config).await.unwrap();
1163
1164        // 5. Receive the messages and verify them
1165        let received1 = source.receive().await.unwrap();
1166        let _ = (received1.commit)(crate::traits::MessageDisposition::Ack).await; // Commit is a no-op, but we should call it
1167
1168        assert_eq!(received1.message.message_id, msg1.message_id);
1169        assert_eq!(received1.message.payload, msg1.payload);
1170
1171        let batch = source.receive_batch(1).await.unwrap();
1172        let (received_msgs, commit2) = (batch.messages, batch.commit);
1173        let len = received_msgs.len();
1174        let received_msg2 = received_msgs.into_iter().next().unwrap();
1175        let _ = commit2(vec![crate::traits::MessageDisposition::Ack; len]).await;
1176        assert_eq!(received_msg2.message_id, msg2.message_id);
1177        assert_eq!(received_msg2.payload, msg2.payload);
1178
1179        // 6. Verify that reading again waits for new data (timeout)
1180        let result = tokio::time::timeout(
1181            std::time::Duration::from_millis(200),
1182            source.receive_batch(1),
1183        )
1184        .await;
1185        assert!(result.is_err(), "Expected timeout waiting for new data");
1186    }
1187
1188    #[tokio::test]
1189    async fn test_file_sink_creates_directory() {
1190        let dir = tempdir().unwrap();
1191        let nested_dir_path = dir.path().join("nested");
1192        let file_path = nested_dir_path.join("test.log");
1193
1194        let config = FileConfig {
1195            path: file_path.to_str().unwrap().to_string(),
1196            ..Default::default()
1197        };
1198        let sink_result = FilePublisher::new(&config).await;
1199
1200        assert!(sink_result.is_ok());
1201        assert!(nested_dir_path.exists());
1202        assert!(file_path.exists());
1203    }
1204
1205    #[tokio::test]
1206    async fn test_file_consumer_consume_mode() {
1207        let dir = tempdir().unwrap();
1208        let file_path = dir.path().join("consume.log");
1209        let file_path_str = file_path.to_str().unwrap().to_string();
1210
1211        // Write 3 lines
1212        tokio::fs::write(&file_path, b"line1\nline2\nline3\n")
1213            .await
1214            .unwrap();
1215
1216        let config = FileConfig {
1217            path: file_path_str,
1218            mode: Some(FileConsumerMode::Consume { delete: true }),
1219            ..Default::default()
1220        };
1221        let mut consumer = FileConsumer::new(&config).await.unwrap();
1222
1223        // Receive first message
1224        let received1 = consumer.receive().await.unwrap();
1225        assert_eq!(received1.message.payload.as_ref(), b"line1");
1226
1227        // Commit first message (should remove line1)
1228        (received1.commit)(crate::traits::MessageDisposition::Ack)
1229            .await
1230            .unwrap();
1231
1232        // Verify file content - wait for async deletion
1233        let mut content = String::new();
1234        for _ in 0..20 {
1235            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1236            if content == "line2\nline3\n" {
1237                break;
1238            }
1239            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1240        }
1241        assert_eq!(content, "line2\nline3\n");
1242
1243        // Receive second message
1244        let received2 = consumer.receive().await.unwrap();
1245        assert_eq!(received2.message.payload.as_ref(), b"line2");
1246        (received2.commit)(crate::traits::MessageDisposition::Ack)
1247            .await
1248            .unwrap();
1249
1250        // Receive third message
1251        let received3 = consumer.receive().await.unwrap();
1252        assert_eq!(received3.message.payload.as_ref(), b"line3");
1253        (received3.commit)(crate::traits::MessageDisposition::Ack)
1254            .await
1255            .unwrap();
1256
1257        // Verify file is empty
1258        for _ in 0..20 {
1259            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1260            if content.is_empty() {
1261                break;
1262            }
1263            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1264        }
1265        assert_eq!(content, "");
1266    }
1267
1268    #[tokio::test]
1269    async fn test_file_consumer_nack_behavior() {
1270        let dir = tempdir().unwrap();
1271        let file_path = dir.path().join("nack.log");
1272        let file_path_str = file_path.to_str().unwrap().to_string();
1273
1274        // Write 2 lines
1275        tokio::fs::write(&file_path, b"msg1\nmsg2\n").await.unwrap();
1276
1277        let config = FileConfig {
1278            path: file_path_str.clone(),
1279            mode: Some(FileConsumerMode::Consume { delete: true }),
1280            ..Default::default()
1281        };
1282        let mut consumer = FileConsumer::new(&config).await.unwrap();
1283
1284        // 1. Receive batch (should get msg1)
1285        let batch1 = consumer.receive_batch(1).await.unwrap();
1286        assert_eq!(batch1.messages.len(), 1);
1287        assert_eq!(batch1.messages[0].payload.as_ref(), b"msg1");
1288
1289        // 2. Nack the batch
1290        (batch1.commit)(vec![crate::traits::MessageDisposition::Nack])
1291            .await
1292            .unwrap();
1293
1294        // 3. Receive again - should get msg1 again because it wasn't removed
1295        let batch2 = consumer.receive_batch(1).await.unwrap();
1296        assert_eq!(batch2.messages.len(), 1);
1297        assert_eq!(batch2.messages[0].payload.as_ref(), b"msg1");
1298
1299        // 4. Ack
1300        (batch2.commit)(vec![crate::traits::MessageDisposition::Ack])
1301            .await
1302            .unwrap();
1303
1304        // 5. Receive next - should get msg2
1305        let batch3 = consumer.receive_batch(1).await.unwrap();
1306        assert_eq!(batch3.messages.len(), 1);
1307        assert_eq!(batch3.messages[0].payload.as_ref(), b"msg2");
1308    }
1309
1310    #[tokio::test]
1311    async fn test_file_consumer_consume_no_delete() {
1312        let dir = tempdir().unwrap();
1313        let file_path = dir.path().join("consume_no_delete.log");
1314        let file_path_str = file_path.to_str().unwrap().to_string();
1315
1316        // Write 3 lines
1317        tokio::fs::write(&file_path, b"line1\nline2\nline3\n")
1318            .await
1319            .unwrap();
1320
1321        let config = FileConfig {
1322            path: file_path_str.clone(),
1323            ..Default::default()
1324        };
1325        let mut consumer = FileConsumer::new(&config).await.unwrap();
1326
1327        // Receive first message
1328        let received1 = consumer.receive().await.unwrap();
1329        assert_eq!(received1.message.payload.as_ref(), b"line1");
1330
1331        // Commit first message (should NOT remove line1)
1332        (received1.commit)(crate::traits::MessageDisposition::Ack)
1333            .await
1334            .unwrap();
1335
1336        // Give some time for any potential (but unwanted) background deletion to happen
1337        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1338
1339        // Verify file content remains unchanged
1340        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1341        assert_eq!(content, "line1\nline2\nline3\n");
1342
1343        // Receive second message
1344        let received2 = consumer.receive().await.unwrap();
1345        assert_eq!(received2.message.payload.as_ref(), b"line2");
1346    }
1347
1348    #[tokio::test]
1349    async fn test_file_consumer_subscribe_mode() {
1350        let dir = tempdir().unwrap();
1351        let file_path = dir.path().join("subscribe.log");
1352        let file_path_str = file_path.to_str().unwrap().to_string();
1353
1354        // Write initial content
1355        tokio::fs::write(&file_path, b"line1\n").await.unwrap();
1356
1357        let config = FileConfig {
1358            path: file_path_str.clone(),
1359            mode: Some(FileConsumerMode::Subscribe { delete: false }),
1360            ..Default::default()
1361        };
1362
1363        let mut consumer = FileConsumer::new(&config).await.unwrap();
1364
1365        // Give the background tailer a moment to initialize and find its starting position.
1366        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1367
1368        // Append new line
1369        {
1370            let mut file = OpenOptions::new()
1371                .append(true)
1372                .open(&file_path)
1373                .await
1374                .unwrap();
1375            file.write_all(b"line2\n").await.unwrap();
1376        }
1377
1378        // Receive new line
1379        let received2 = consumer.receive_batch(2).await.unwrap();
1380        assert_eq!(received2.messages.len(), 1);
1381        assert_eq!(received2.messages[0].payload.as_ref(), b"line2");
1382        (received2.commit)(vec![crate::traits::MessageDisposition::Ack])
1383            .await
1384            .unwrap();
1385
1386        // Verify file content is unchanged
1387        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1388        assert_eq!(content, "line1\nline2\n");
1389    }
1390
1391    #[tokio::test]
1392    async fn test_file_consumer_consume_explicit_delete() {
1393        let dir = tempdir().unwrap();
1394        let file_path = dir.path().join("consume_explicit_delete.log");
1395        let file_path_str = file_path.to_str().unwrap().to_string();
1396
1397        tokio::fs::write(&file_path, b"line1\n").await.unwrap();
1398
1399        let config = FileConfig {
1400            path: file_path_str.clone(),
1401            mode: Some(FileConsumerMode::Consume { delete: true }),
1402            ..Default::default()
1403        };
1404        let mut consumer = FileConsumer::new(&config).await.unwrap();
1405
1406        let received = consumer.receive().await.unwrap();
1407        assert_eq!(received.message.payload.as_ref(), b"line1");
1408
1409        (received.commit)(crate::traits::MessageDisposition::Ack)
1410            .await
1411            .unwrap();
1412
1413        // Verify file becomes empty
1414        let mut content = String::new();
1415        for _ in 0..20 {
1416            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1417            if content.is_empty() {
1418                break;
1419            }
1420            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1421        }
1422        assert_eq!(content, "");
1423    }
1424
1425    #[tokio::test]
1426    async fn test_file_consumer_subscribe_with_delete() {
1427        let dir = tempdir().unwrap();
1428        let file_path = dir.path().join("subscribe_delete.log");
1429        let file_path_str = file_path.to_str().unwrap().to_string();
1430
1431        tokio::fs::write(&file_path, b"line1\n").await.unwrap();
1432
1433        let config = FileConfig {
1434            path: file_path_str.clone(),
1435            mode: Some(FileConsumerMode::Subscribe { delete: true }),
1436            ..Default::default()
1437        };
1438
1439        let mut sub1 = FileConsumer::new(&config).await.unwrap();
1440        let mut sub2 = FileConsumer::new(&config).await.unwrap();
1441
1442        let msg1 = sub1.receive().await.unwrap();
1443        assert_eq!(msg1.message.payload.as_ref(), b"line1");
1444
1445        let msg2 = sub2.receive().await.unwrap();
1446        assert_eq!(msg2.message.payload.as_ref(), b"line1");
1447
1448        // Sub1 acks. File should NOT be deleted yet.
1449        (msg1.commit)(crate::traits::MessageDisposition::Ack)
1450            .await
1451            .unwrap();
1452
1453        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1454        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1455        assert_eq!(content, "line1\n");
1456
1457        // Sub2 acks. File should be deleted.
1458        (msg2.commit)(crate::traits::MessageDisposition::Ack)
1459            .await
1460            .unwrap();
1461
1462        let mut content = String::new();
1463        for _ in 0..20 {
1464            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1465            if content.is_empty() {
1466                break;
1467            }
1468            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1469        }
1470        assert_eq!(content, "");
1471    }
1472
1473    #[tokio::test]
1474    async fn test_file_consumer_subscribe_explicit_no_delete() {
1475        let dir = tempdir().unwrap();
1476        let file_path = dir.path().join("subscribe_no_delete.log");
1477        let file_path_str = file_path.to_str().unwrap().to_string();
1478
1479        tokio::fs::write(&file_path, b"line1\n").await.unwrap();
1480
1481        let config = FileConfig {
1482            path: file_path_str.clone(),
1483            mode: Some(FileConsumerMode::Subscribe { delete: false }),
1484            ..Default::default()
1485        };
1486
1487        let mut consumer = FileConsumer::new(&config).await.unwrap();
1488        // Give the background tailer a moment to initialize and find its starting position.
1489        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1490        {
1491            let mut file = OpenOptions::new()
1492                .append(true)
1493                .open(&file_path)
1494                .await
1495                .unwrap();
1496            file.write_all(b"line2\n").await.unwrap();
1497        }
1498
1499        let received = consumer.receive().await.unwrap();
1500        assert_eq!(received.message.payload.as_ref(), b"line2");
1501
1502        (received.commit)(crate::traits::MessageDisposition::Ack)
1503            .await
1504            .unwrap();
1505
1506        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1507        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1508        assert_eq!(content, "line1\nline2\n");
1509    }
1510
1511    use crate::models::{Endpoint, EndpointType, Route};
1512
1513    #[tokio::test]
1514    async fn test_route_file_consume_explicit_delete() {
1515        let dir = tempdir().unwrap();
1516        let file_path = dir.path().join("route_consume_explicit_delete.log");
1517        let file_path_str = file_path.to_str().unwrap().to_string();
1518        tokio::fs::write(&file_path, b"msg1\n").await.unwrap();
1519
1520        let input = Endpoint::new(EndpointType::File(FileConfig {
1521            path: file_path_str.clone(),
1522            mode: Some(FileConsumerMode::Consume { delete: true }),
1523            ..Default::default()
1524        }));
1525        let output = Endpoint::new_memory("out_consume_explicit_delete", 10);
1526        let route = Route::new(input, output.clone());
1527
1528        let handle = route
1529            .run("test_route_consume_explicit_delete")
1530            .await
1531            .unwrap();
1532
1533        let channel = output.channel().unwrap();
1534        // Wait for message
1535        let mut received = Vec::new();
1536        for _ in 0..20 {
1537            if !channel.is_empty() {
1538                received = channel.drain_messages();
1539                break;
1540            }
1541            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1542        }
1543        assert_eq!(received.len(), 1);
1544        assert_eq!(&received[0].payload.to_vec(), b"msg1");
1545
1546        // Verify deletion
1547        let mut content = String::new();
1548        for _ in 0..20 {
1549            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1550            if content.is_empty() {
1551                break;
1552            }
1553            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1554        }
1555        assert_eq!(content, "");
1556
1557        handle.stop().await;
1558    }
1559
1560    #[tokio::test]
1561    async fn test_route_file_subscribe_with_delete() {
1562        let dir = tempdir().unwrap();
1563        let file_path = dir.path().join("route_subscribe_delete.log");
1564        let file_path_str = file_path.to_str().unwrap().to_string();
1565        tokio::fs::write(&file_path, b"msg1\n").await.unwrap();
1566
1567        let input = Endpoint::new(EndpointType::File(FileConfig {
1568            path: file_path_str.clone(),
1569            mode: Some(FileConsumerMode::Subscribe { delete: true }),
1570            ..Default::default()
1571        }));
1572        let output = Endpoint::new_memory("out_subscribe_delete", 10);
1573        let route = Route::new(input, output.clone());
1574
1575        let handle = route.run("test_route_subscribe_delete").await.unwrap();
1576
1577        let channel = output.channel().unwrap();
1578        let mut received = Vec::new();
1579        for _ in 0..20 {
1580            if !channel.is_empty() {
1581                received = channel.drain_messages();
1582                break;
1583            }
1584            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1585        }
1586        assert_eq!(received.len(), 1);
1587
1588        // Verify deletion
1589        let mut content = String::new();
1590        for _ in 0..20 {
1591            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1592            if content.is_empty() {
1593                break;
1594            }
1595            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1596        }
1597        assert_eq!(content, "");
1598
1599        handle.stop().await;
1600    }
1601
1602    #[tokio::test]
1603    async fn test_route_file_subscribe_explicit_no_delete() {
1604        let dir = tempdir().unwrap();
1605        let file_path = dir.path().join("route_subscribe_no_delete.log");
1606        let file_path_str = file_path.to_str().unwrap().to_string();
1607        tokio::fs::write(&file_path, b"msg1\n").await.unwrap();
1608
1609        let input = Endpoint::new(EndpointType::File(FileConfig {
1610            path: file_path_str.clone(),
1611            mode: Some(FileConsumerMode::Subscribe { delete: false }),
1612            ..Default::default()
1613        }));
1614        let output = Endpoint::new_memory("out_subscribe_no_delete", 10);
1615        let route = Route::new(input, output.clone());
1616
1617        let handle = route.run("test_route_subscribe_no_delete").await.unwrap();
1618
1619        let channel = output.channel().unwrap();
1620        let mut received = Vec::new();
1621        for _ in 0..20 {
1622            if !channel.is_empty() {
1623                received = channel.drain_messages();
1624                break;
1625            }
1626            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1627        }
1628        assert_eq!(received.len(), 0);
1629
1630        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1631        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1632        assert_eq!(content, "msg1\n");
1633
1634        handle.stop().await;
1635    }
1636
1637    #[tokio::test]
1638    async fn test_route_file_consume_all_lines() {
1639        let dir = tempdir().unwrap();
1640        let file_path = dir.path().join("consume_all.log");
1641        let file_path_str = file_path.to_str().unwrap().to_string();
1642
1643        // Write 10 lines
1644        let mut content = String::new();
1645        for i in 0..10 {
1646            content.push_str(&format!("msg{}\n", i));
1647        }
1648        tokio::fs::write(&file_path, content).await.unwrap();
1649
1650        let input = Endpoint::new(EndpointType::File(FileConfig {
1651            path: file_path_str.clone(),
1652            mode: Some(FileConsumerMode::Consume { delete: true }),
1653            ..Default::default()
1654        }));
1655        let output = Endpoint::new_memory("out_consume_all", 100);
1656        let route = Route::new(input, output.clone());
1657
1658        let handle = route.run("test_route_consume_all").await.unwrap();
1659
1660        let channel = output.channel().unwrap();
1661        // Wait for messages
1662        let mut received_count = 0;
1663        for _ in 0..100 {
1664            received_count += channel.drain_messages().len();
1665            if received_count >= 10 {
1666                break;
1667            }
1668            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1669        }
1670        assert_eq!(received_count, 10);
1671
1672        // Verify file is empty
1673        let mut content = String::new();
1674        for _ in 0..40 {
1675            content = tokio::fs::read_to_string(&file_path).await.unwrap();
1676            if content.is_empty() {
1677                break;
1678            }
1679            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1680        }
1681        assert_eq!(content, "");
1682
1683        handle.stop().await;
1684    }
1685
1686    #[tokio::test]
1687    async fn test_file_consumer_group_id_persistence() {
1688        let dir = tempdir().unwrap();
1689        let file_path = dir.path().join("group_id.log");
1690        let file_path_str = file_path.to_str().unwrap().to_string();
1691        let offset_path = dir.path().join("group_id.log.my_group.offset");
1692
1693        // Write initial content
1694        tokio::fs::write(&file_path, b"msg1\nmsg2\n").await.unwrap();
1695
1696        let config = FileConfig {
1697            path: file_path_str.clone(),
1698            mode: Some(FileConsumerMode::GroupSubscribe {
1699                group_id: "my_group".to_string(),
1700                read_from_tail: false,
1701            }),
1702            ..Default::default()
1703        };
1704
1705        // 1. First consumer reads msg1
1706        let mut consumer1 = FileConsumer::new(&config).await.unwrap();
1707        // Allow thread to start
1708        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1709
1710        let batch1 = consumer1.receive_batch(1).await.unwrap();
1711        assert_eq!(batch1.messages[0].payload.as_ref(), b"msg1");
1712
1713        // Commit msg1 -> should write offset
1714        (batch1.commit)(vec![crate::traits::MessageDisposition::Ack])
1715            .await
1716            .unwrap();
1717
1718        // Verify offset file exists and contains correct offset (length of "msg1\n" is 5)
1719        let offset_content = tokio::fs::read_to_string(&offset_path).await.unwrap();
1720        assert_eq!(offset_content, "5");
1721
1722        drop(consumer1);
1723
1724        // 2. Second consumer (simulating restart) should start from offset 5 (msg2)
1725        let mut consumer2 = FileConsumer::new(&config).await.unwrap();
1726        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1727
1728        let batch2 = consumer2.receive_batch(1).await.unwrap();
1729        assert_eq!(batch2.messages[0].payload.as_ref(), b"msg2");
1730
1731        (batch2.commit)(vec![crate::traits::MessageDisposition::Ack])
1732            .await
1733            .unwrap();
1734
1735        // Verify offset updated (5 + length of "msg2\n" (5) = 10)
1736        let offset_content = tokio::fs::read_to_string(&offset_path).await.unwrap();
1737        assert_eq!(offset_content, "10");
1738    }
1739
1740    #[tokio::test]
1741    async fn test_file_consumer_group_id_init_from_start() {
1742        let dir = tempdir().unwrap();
1743        let file_path = dir.path().join("group_id_start.log");
1744        let file_path_str = file_path.to_str().unwrap().to_string();
1745
1746        // Write initial content
1747        tokio::fs::write(&file_path, b"msg1\nmsg2\n").await.unwrap();
1748
1749        let config = FileConfig {
1750            path: file_path_str.clone(),
1751            mode: Some(FileConsumerMode::GroupSubscribe {
1752                group_id: "my_group_start".to_string(),
1753                read_from_tail: false,
1754            }),
1755            ..Default::default()
1756        };
1757
1758        // Consumer should start from beginning (msg1)
1759        let mut consumer = FileConsumer::new(&config).await.unwrap();
1760        // Allow thread to start
1761        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1762
1763        let batch = consumer.receive_batch(2).await.unwrap();
1764        assert_eq!(batch.messages.len(), 2);
1765        assert_eq!(batch.messages[0].payload.as_ref(), b"msg1");
1766        assert_eq!(batch.messages[1].payload.as_ref(), b"msg2");
1767    }
1768
1769    #[tokio::test]
1770    async fn test_file_tail_concurrent_publish_and_consume() {
1771        // This test verifies that the tail reader can work concurrently with the publisher
1772        // writing to the file. This is critical for Windows compatibility where file locking
1773        // semantics may prevent concurrent access if not handled correctly.
1774        // Note: Even though this runs in the same process, Windows file sharing modes are
1775        // enforced per-handle. Since the consumer does not participate in the `FILE_LOCKS`
1776        // mutex used by the publisher, this effectively tests that the OS allows the
1777        // publisher to open/write while the consumer has the file open for reading.
1778        let dir = tempdir().unwrap();
1779        let file_path = dir.path().join("concurrent.log");
1780        let file_path_str = file_path.to_str().unwrap().to_string();
1781
1782        // Create file with initial message
1783        tokio::fs::write(&file_path, b"msg0\n").await.unwrap();
1784
1785        let config = FileConfig {
1786            path: file_path_str.clone(),
1787            mode: Some(FileConsumerMode::Subscribe { delete: false }),
1788            ..Default::default()
1789        };
1790
1791        // Start the tail consumer
1792        let mut consumer = FileConsumer::new(&config).await.unwrap();
1793        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1794
1795        // Spawn a task that continuously publishes messages while the consumer is reading
1796        let publisher_path = file_path_str.clone();
1797        let publish_handle = tokio::spawn(async move {
1798            let pub_config = FileConfig {
1799                path: publisher_path,
1800                mode: Some(FileConsumerMode::Subscribe { delete: false }),
1801                ..Default::default()
1802            };
1803            let publisher = FilePublisher::new(&pub_config).await.unwrap();
1804
1805            // Send enough messages to ensure overlap between reading and writing
1806            for i in 1..=100 {
1807                let msg = msg!(json!({"id": i, "data": format!("message_{}", i)}));
1808                publisher.send_batch(vec![msg]).await.unwrap();
1809                // Small delay to allow consumer to catch up and potentially open the file
1810                if i % 10 == 0 {
1811                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1812                }
1813            }
1814        });
1815
1816        // Consumer should be able to read messages while publisher is writing
1817        let mut received_count = 0;
1818        let mut message_ids = Vec::new();
1819
1820        // We expect 100 published messages (initial msg0 is skipped in Subscribe mode)
1821        let expected_count = 100;
1822        let start = std::time::Instant::now();
1823
1824        while received_count < expected_count {
1825            if start.elapsed() > std::time::Duration::from_secs(10) {
1826                break;
1827            }
1828            match tokio::time::timeout(
1829                std::time::Duration::from_millis(200),
1830                consumer.receive_batch(10),
1831            )
1832            .await
1833            {
1834                Ok(Ok(batch)) => {
1835                    for msg in &batch.messages {
1836                        received_count += 1;
1837                        if let Ok(json_msg) =
1838                            serde_json::from_slice::<serde_json::Value>(&msg.payload)
1839                        {
1840                            if let Some(id) = json_msg.get("id").and_then(|v| v.as_i64()) {
1841                                message_ids.push(id);
1842                            }
1843                        }
1844                    }
1845                    (batch.commit)(vec![
1846                        crate::traits::MessageDisposition::Ack;
1847                        batch.messages.len()
1848                    ])
1849                    .await
1850                    .unwrap();
1851                }
1852                Ok(Err(_)) => break, // Stream ended
1853                Err(_) => continue,  // Timeout waiting for message
1854            }
1855        }
1856
1857        publish_handle.await.unwrap();
1858
1859        // Verify we received at least some messages from the publisher
1860        // We should receive the messages from the concurrent publisher
1861        assert_eq!(
1862            received_count, expected_count,
1863            "Expected {} messages, got {}. This may indicate file locking issues on this platform.",
1864            expected_count, received_count
1865        );
1866
1867        // Verify the file still exists and can be read (not locked/deleted)
1868        let final_content = tokio::fs::read_to_string(&file_path)
1869            .await
1870            .expect("File should still be readable after concurrent access");
1871        assert!(
1872            !final_content.is_empty(),
1873            "File should contain messages after concurrent access"
1874        );
1875    }
1876
1877    /// Simulates an external process (like a Python script or log writer) appending to the file.
1878    /// Unlike `test_file_tail_concurrent_publish_and_consume`, this test:
1879    /// 1. Does not use `FilePublisher` (bypassing internal `FILE_LOCKS`).
1880    /// 2. Keeps the file handle open across multiple writes (simulating a long-running writer),
1881    ///    which stresses file locking/sharing semantics on OSs like Windows.
1882    #[tokio::test]
1883    async fn test_file_subscribe_concurrent_external_write() {
1884        let dir = tempdir().unwrap();
1885        let file_path = dir.path().join("external_write.log");
1886        let file_path_str = file_path.to_str().unwrap().to_string();
1887
1888        // Create empty file
1889        tokio::fs::write(&file_path, b"").await.unwrap();
1890
1891        let config = FileConfig {
1892            path: file_path_str.clone(),
1893            mode: Some(FileConsumerMode::Subscribe { delete: false }),
1894            ..Default::default()
1895        };
1896
1897        let mut consumer = FileConsumer::new(&config).await.unwrap();
1898
1899        // Give the background tailer a moment to initialize
1900        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1901
1902        let file_path_clone = file_path.clone();
1903        let write_task = tokio::spawn(async move {
1904            let mut file = OpenOptions::new()
1905                .append(true)
1906                .open(&file_path_clone)
1907                .await
1908                .unwrap();
1909
1910            for i in 0..5 {
1911                let line = format!("message {}\n", i);
1912                file.write_all(line.as_bytes()).await.unwrap();
1913                file.flush().await.unwrap();
1914                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1915            }
1916        });
1917
1918        for i in 0..5 {
1919            let received =
1920                tokio::time::timeout(std::time::Duration::from_secs(5), consumer.receive())
1921                    .await
1922                    .expect("Timed out waiting for message")
1923                    .unwrap();
1924
1925            let expected_payload = format!("message {}", i);
1926            assert_eq!(received.message.get_payload_str().trim(), expected_payload);
1927            (received.commit)(crate::traits::MessageDisposition::Ack)
1928                .await
1929                .unwrap();
1930        }
1931
1932        write_task.await.unwrap();
1933    }
1934
1935    #[tokio::test]
1936    async fn test_file_custom_delimiter() {
1937        let dir = tempdir().unwrap();
1938        let file_path = dir.path().join("custom_delim.log");
1939        let file_path_str = file_path.to_str().unwrap().to_string();
1940
1941        let config = FileConfig {
1942            path: file_path_str.clone(),
1943            delimiter: Some("|".to_string()),
1944            mode: Some(FileConsumerMode::Consume { delete: false }),
1945            ..Default::default()
1946        };
1947
1948        let publisher = FilePublisher::new(&config).await.unwrap();
1949        let mut consumer = FileConsumer::new(&config).await.unwrap();
1950
1951        let msg1 = crate::CanonicalMessage::from("msg1").with_raw_format();
1952        let msg2 = crate::CanonicalMessage::from("msg2").with_raw_format();
1953
1954        publisher.send_batch(vec![msg1, msg2]).await.unwrap();
1955        publisher.flush().await.unwrap();
1956        drop(publisher); // Release lock
1957
1958        // Verify file content has pipes
1959        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1960        assert_eq!(content, "msg1|msg2|");
1961
1962        let received1 = consumer.receive().await.unwrap();
1963        assert_eq!(received1.message.get_payload_str(), "msg1");
1964
1965        let received2 = consumer.receive().await.unwrap();
1966        assert_eq!(received2.message.get_payload_str(), "msg2");
1967    }
1968
1969    #[tokio::test]
1970    async fn test_file_xml_delimiter() {
1971        let dir = tempdir().unwrap();
1972        let file_path = dir.path().join("xml_delim.log");
1973        let file_path_str = file_path.to_str().unwrap().to_string();
1974
1975        let config = FileConfig {
1976            path: file_path_str.clone(),
1977            delimiter: Some("</message>".to_string()),
1978            mode: Some(FileConsumerMode::Consume { delete: false }),
1979            ..Default::default()
1980        };
1981
1982        let publisher = FilePublisher::new(&config).await.unwrap();
1983        let mut consumer = FileConsumer::new(&config).await.unwrap();
1984
1985        let msg1 = crate::CanonicalMessage::from("<xml>content1").with_raw_format();
1986        let msg2 = crate::CanonicalMessage::from("<xml>content2").with_raw_format();
1987
1988        publisher.send_batch(vec![msg1, msg2]).await.unwrap();
1989        publisher.flush().await.unwrap();
1990        drop(publisher); // Release lock
1991
1992        // Verify file content has tags
1993        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
1994        assert_eq!(content, "<xml>content1</message><xml>content2</message>");
1995
1996        let received1 = consumer.receive().await.unwrap();
1997        assert_eq!(received1.message.get_payload_str(), "<xml>content1");
1998
1999        let received2 = consumer.receive().await.unwrap();
2000        assert_eq!(received2.message.get_payload_str(), "<xml>content2");
2001    }
2002
2003    #[tokio::test]
2004    async fn test_file_formats_and_fallbacks() {
2005        let dir = tempdir().unwrap();
2006
2007        // 1. Test JSON Format Roundtrip
2008        let json_path = dir.path().join("json.log");
2009        let json_config = FileConfig {
2010            path: json_path.to_str().unwrap().to_string(),
2011            format: FileFormat::Json,
2012            ..Default::default()
2013        };
2014
2015        let json_publisher = FilePublisher::new(&json_config).await.unwrap();
2016        let mut json_consumer = FileConsumer::new(&json_config).await.unwrap();
2017
2018        let json_payload = json!({"key": "value", "num": 123});
2019        let msg = msg!(json_payload.clone());
2020
2021        json_publisher.send_batch(vec![msg.clone()]).await.unwrap();
2022        json_publisher.flush().await.unwrap();
2023        drop(json_publisher); // Release lock
2024
2025        let received = json_consumer.receive().await.unwrap();
2026        let received_json: serde_json::Value =
2027            serde_json::from_slice(&received.message.payload).unwrap();
2028        assert_eq!(received_json, json_payload);
2029        (received.commit)(crate::traits::MessageDisposition::Ack)
2030            .await
2031            .unwrap();
2032
2033        // 2. Test Text Format Roundtrip
2034        let text_path = dir.path().join("text.log");
2035        let text_config = FileConfig {
2036            path: text_path.to_str().unwrap().to_string(),
2037            format: FileFormat::Text,
2038            ..Default::default()
2039        };
2040
2041        let text_publisher = FilePublisher::new(&text_config).await.unwrap();
2042        let mut text_consumer = FileConsumer::new(&text_config).await.unwrap();
2043
2044        let text_payload = "Hello World";
2045        let msg = crate::CanonicalMessage::from(text_payload);
2046
2047        text_publisher.send_batch(vec![msg.clone()]).await.unwrap();
2048        text_publisher.flush().await.unwrap();
2049        drop(text_publisher);
2050
2051        let received = text_consumer.receive().await.unwrap();
2052        assert_eq!(received.message.get_payload_str(), text_payload);
2053        (received.commit)(crate::traits::MessageDisposition::Ack)
2054            .await
2055            .unwrap();
2056
2057        // 3. Test Fallback (Corrupted/Raw line in Json format)
2058        // We append a raw line that isn't the expected JSON wrapper structure
2059        {
2060            let mut file = OpenOptions::new()
2061                .append(true)
2062                .open(&json_path)
2063                .await
2064                .unwrap();
2065            file.write_all(b"Not a JSON wrapper\n").await.unwrap();
2066        }
2067
2068        let received_fallback = json_consumer.receive().await.unwrap();
2069        // Should be treated as raw
2070        assert_eq!(
2071            received_fallback.message.get_payload_str(),
2072            "Not a JSON wrapper"
2073        );
2074        assert_eq!(
2075            received_fallback
2076                .message
2077                .metadata
2078                .get("mq_bridge.original_format")
2079                .map(|s| s.as_str()),
2080            Some("raw")
2081        );
2082    }
2083}