Skip to main content

rusty_ts/
lib.rs

1//! # rusty-ts
2//!
3//! A Rust port of the moreutils `ts` utility: prefix each line of stdin with
4//! a timestamp. The CLI binary is the primary user-facing surface; this
5//! library exposes the same line-timestamping logic for programmatic reuse.
6//!
7//! ## Quick example — library API
8//!
9//! ```no_run
10//! use rusty_ts::{TimestamperBuilder, Format, TimezoneSource};
11//! use std::io::{BufReader, Cursor};
12//!
13//! let mut ts = TimestamperBuilder::new()
14//!     .format(Format::Strftime("%Y-%m-%d %H:%M:%S".into()))
15//!     .timezone(TimezoneSource::Utc)
16//!     .build()
17//!     .expect("valid configuration");
18//!
19//! let input = BufReader::new(Cursor::new(b"hello\nworld\n".to_vec()));
20//! for chunk in ts.prefix_lines(input) {
21//!     let bytes = chunk.expect("io ok");
22//!     print!("{}", String::from_utf8_lossy(&bytes));
23//! }
24//! ```
25//!
26//! ## Library-without-binary
27//!
28//! ```toml
29//! [dependencies]
30//! rusty-ts = { version = "0.1", default-features = false }
31//! ```
32//!
33//! Disabling `default-features` drops the `cli` feature and skips `clap`,
34//! `clap_complete`, and `anyhow` from the dependency closure.
35//!
36//! ## License
37//!
38//! Licensed under either of Apache-2.0 or MIT at your option.
39
40#![cfg_attr(docsrs, feature(doc_cfg))]
41
42#[cfg(feature = "cli")]
43pub mod cli;
44#[cfg(feature = "cli")]
45pub mod compat_matrix;
46#[cfg(feature = "cli")]
47pub mod completions;
48pub mod error;
49pub mod mode;
50pub mod pipeline;
51pub mod relative;
52pub mod time;
53
54pub use error::Error;
55pub use mode::{CompatibilityMode, ExplicitChoice};
56pub use time::tz::TimezoneSource;
57
58use crate::pipeline::{PrefixConfig, PrefixSource};
59use crate::time::clock::{Clock, Wall};
60use crate::time::format;
61
62// ───────────────────────── Public Library API ──────────────────────────────
63
64/// Strftime format selector for `TimestamperBuilder`. `#[non_exhaustive]` so
65/// future variants (e.g., a precompiled format) can be added in minor
66/// releases without breaking semver.
67///
68/// # Example
69///
70/// ```
71/// use rusty_ts::{Format, TimestamperBuilder};
72///
73/// // Use the moreutils default format.
74/// let ts = TimestamperBuilder::new()
75///     .format(Format::Default)
76///     .build()
77///     .unwrap();
78///
79/// // Use a custom strftime spec, including moreutils fractional extensions.
80/// let ts = TimestamperBuilder::new()
81///     .format(Format::Strftime("%Y-%m-%d %H:%M:%.S".into()))
82///     .build()
83///     .unwrap();
84/// # let _ = ts;
85/// ```
86#[non_exhaustive]
87#[derive(Debug, Default, Clone)]
88pub enum Format {
89    /// Use the moreutils default format (`%b %d %H:%M:%S`).
90    #[default]
91    Default,
92    /// Use the supplied strftime spec, including `%.S` / `%.s` fractional
93    /// extensions.
94    Strftime(String),
95}
96
97/// Elapsed-time anchor selector. `Absolute` is the default; the other
98/// variants correspond to the CLI `-i` / `-s` flags.
99///
100/// # Example
101///
102/// ```
103/// use rusty_ts::{ElapsedAnchor, TimestamperBuilder};
104///
105/// // Render elapsed time since program start (matches CLI `-s`).
106/// let ts = TimestamperBuilder::new()
107///     .elapsed(ElapsedAnchor::SinceProgramStart)
108///     .build()
109///     .unwrap();
110/// # let _ = ts;
111/// ```
112#[non_exhaustive]
113#[derive(Debug, Clone, Copy, Default)]
114pub enum ElapsedAnchor {
115    /// Absolute wall-clock time (no elapsed anchor). Default.
116    #[default]
117    Absolute,
118    /// Elapsed since the previous input line (`-i`).
119    SincePreviousLine,
120    /// Elapsed since program start (`-s`).
121    SinceProgramStart,
122}
123
124/// Builder for [`Timestamper`]. Every chain method is `#[must_use]` so silent
125/// misuse is caught at compile time. `build()` performs post-configuration
126/// validation and returns the same typed errors the CLI's post-parse
127/// validation produces (e.g., [`Error::InvalidUtcWithNamedTz`] for FR-020
128/// mirrored at the library layer).
129///
130/// # Example
131///
132/// ```
133/// use rusty_ts::{TimestamperBuilder, Format, TimezoneSource};
134///
135/// // Configure via the structured `utc()` / `tz_name()` methods that mirror
136/// // the CLI `-u` and `--tz=<IANA>` flags.
137/// let ts = TimestamperBuilder::new()
138///     .format(Format::Strftime("%Y-%m-%d %H:%M:%S".into()))
139///     .utc(true)
140///     .build()
141///     .expect("valid configuration");
142/// # let _ = ts;
143///
144/// // Conflicting configuration is caught at build() per FR-020.
145/// let result = TimestamperBuilder::new()
146///     .utc(true)
147///     .tz_name("Asia/Tokyo")
148///     .build();
149/// assert!(result.is_err());
150/// ```
151#[derive(Debug, Clone, Default)]
152pub struct TimestamperBuilder {
153    format: Format,
154    /// Mirrors the CLI `-u` / `--utc` flag.
155    utc_requested: bool,
156    /// Mirrors the CLI `--tz=<IANA>` flag.
157    named_tz: Option<String>,
158    /// Direct enum override; lower-level escape hatch. When set, supersedes
159    /// `utc_requested` and `named_tz` (advanced consumers only).
160    timezone_override: Option<TimezoneSource>,
161    compat: CompatibilityMode,
162    elapsed: ElapsedAnchor,
163}
164
165impl TimestamperBuilder {
166    /// Start a new builder with all-default configuration.
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Set the strftime format selector.
172    #[must_use]
173    pub fn format(mut self, format: Format) -> Self {
174        self.format = format;
175        self
176    }
177
178    /// Request UTC rendering (mirrors the CLI `-u` / `--utc` flag).
179    /// Conflicts with `tz_name`; combined use causes `build()` to return
180    /// [`Error::InvalidUtcWithNamedTz`] per FR-020.
181    #[must_use]
182    pub fn utc(mut self, utc: bool) -> Self {
183        self.utc_requested = utc;
184        self
185    }
186
187    /// Request rendering in a named IANA timezone (mirrors the CLI
188    /// `--tz=<IANA>` flag). Conflicts with `utc(true)`. The name is
189    /// resolved at `build()` time; an unrecognised name produces
190    /// [`Error::InvalidIanaName`].
191    #[must_use]
192    pub fn tz_name(mut self, name: impl Into<String>) -> Self {
193        self.named_tz = Some(name.into());
194        self
195    }
196
197    /// Low-level escape hatch: set the timezone source directly. When set,
198    /// overrides `utc()` and `tz_name()` configuration. Most callers should
199    /// prefer the structured `utc()` / `tz_name()` methods, which mirror the
200    /// CLI flag surface and provide the FR-020 mutex enforcement.
201    #[must_use]
202    pub fn timezone(mut self, tz: TimezoneSource) -> Self {
203        self.timezone_override = Some(tz);
204        self
205    }
206
207    /// Set the compatibility mode. Default is `CompatibilityMode::Default`.
208    #[must_use]
209    pub fn compat(mut self, mode: CompatibilityMode) -> Self {
210        self.compat = mode;
211        self
212    }
213
214    /// Set the elapsed-time anchor. Default is `Absolute`.
215    #[must_use]
216    pub fn elapsed(mut self, anchor: ElapsedAnchor) -> Self {
217        self.elapsed = anchor;
218        self
219    }
220
221    /// Finalize the builder. Returns a configured [`Timestamper`] or an
222    /// [`Error`] if the configuration is invalid.
223    ///
224    /// Validation:
225    /// - `utc(true)` + `tz_name(...)` together → [`Error::InvalidUtcWithNamedTz`]
226    ///   (library-layer mirror of FR-020).
227    /// - `tz_name("...")` with unrecognised IANA name → [`Error::InvalidIanaName`].
228    /// - `timezone(...)` low-level override bypasses the above and uses
229    ///   whatever variant was supplied directly.
230    pub fn build(self) -> Result<Timestamper, Error> {
231        // FR-020 library-layer mirror: utc + named tz is invalid.
232        if self.utc_requested {
233            if let Some(name) = &self.named_tz {
234                return Err(Error::InvalidUtcWithNamedTz { tz: name.clone() });
235            }
236        }
237
238        // Resolve the timezone source from the configured fields. The
239        // low-level `timezone_override` wins if supplied.
240        let timezone = if let Some(direct) = self.timezone_override {
241            direct
242        } else if self.utc_requested {
243            TimezoneSource::Utc
244        } else if let Some(name) = self.named_tz {
245            TimezoneSource::named(&name)?
246        } else {
247            TimezoneSource::Local
248        };
249
250        Ok(Timestamper {
251            format: self.format,
252            timezone,
253            compat: self.compat,
254            elapsed: self.elapsed,
255        })
256    }
257}
258
259/// Configured line-timestamping engine. Cheap to construct (no IO until
260/// `prefix_lines` is called). Marked `#[non_exhaustive]` for future evolution.
261///
262/// # Example
263///
264/// ```
265/// use rusty_ts::{TimestamperBuilder, Format, TimezoneSource};
266/// use std::io::Cursor;
267///
268/// let ts = TimestamperBuilder::new()
269///     .format(Format::Strftime("[%H:%M:%S]".into()))
270///     .utc(true)
271///     .build()
272///     .unwrap();
273///
274/// let input = Cursor::new(b"hello\n".to_vec());
275/// let chunks: Vec<Vec<u8>> = ts
276///     .prefix_lines(input)
277///     .collect::<Result<_, _>>()
278///     .unwrap();
279/// assert!(chunks[0].ends_with(b" hello\n"));
280/// ```
281#[non_exhaustive]
282#[derive(Debug, Clone)]
283pub struct Timestamper {
284    format: Format,
285    timezone: TimezoneSource,
286    compat: CompatibilityMode,
287    elapsed: ElapsedAnchor,
288}
289
290impl Timestamper {
291    /// Drive a [`BufRead`](std::io::BufRead) line source through the
292    /// timestamper. Returns an iterator over byte-typed output chunks
293    /// (`Vec<u8>`); non-UTF-8 payload bytes pass through unchanged per
294    /// FR-011.
295    ///
296    /// The returned iterator yields one `Result<Vec<u8>, io::Error>` per
297    /// input line. Each chunk is the timestamp prefix followed by a
298    /// two-space separator followed by the payload (including any
299    /// trailing newline that was present in the input).
300    pub fn prefix_lines<R: std::io::BufRead>(
301        &self,
302        reader: R,
303    ) -> impl Iterator<Item = Result<Vec<u8>, std::io::Error>> {
304        TimestampingIterator {
305            reader,
306            clock: Wall,
307            timestamper: self.clone(),
308            program_start: None,
309            previous_line_at: None,
310        }
311    }
312
313    /// Convenience adapter for callers who already have UTF-8 `String`
314    /// lines. Returns prefixed `String` chunks. Non-UTF-8 input is impossible
315    /// at this surface — use `prefix_lines` if you need byte fidelity.
316    pub fn prefix_string_lines<I>(&self, lines: I) -> impl Iterator<Item = String>
317    where
318        I: IntoIterator<Item = String>,
319    {
320        let clock = Wall;
321        let program_start = clock.now();
322        let format_spec = self.format_spec().to_owned();
323        let tz = self.timezone.clone();
324        let elapsed = self.elapsed;
325        let mut previous_line_at = program_start;
326
327        lines.into_iter().map(move |line| {
328            let now = clock.now();
329            let prefix = match elapsed {
330                ElapsedAnchor::Absolute => format::format_with(&format_spec, now, &tz),
331                ElapsedAnchor::SincePreviousLine => {
332                    let delta = (now - previous_line_at).to_std().unwrap_or_default();
333                    previous_line_at = now;
334                    elapsed_string(&format_spec, delta)
335                }
336                ElapsedAnchor::SinceProgramStart => {
337                    let delta = (now - program_start).to_std().unwrap_or_default();
338                    elapsed_string(&format_spec, delta)
339                }
340            };
341            format!("{prefix} {line}")
342        })
343    }
344
345    /// Return the effective strftime format spec — either the builder's
346    /// supplied spec or the moreutils default. Used by the iterator
347    /// implementations.
348    pub fn format_spec(&self) -> &str {
349        match &self.format {
350            Format::Default => format::DEFAULT_FORMAT,
351            Format::Strftime(s) => s.as_str(),
352        }
353    }
354
355    /// Return the configured compatibility mode.
356    pub fn compat(&self) -> CompatibilityMode {
357        self.compat
358    }
359
360    /// Return a reference to the configured timezone source.
361    pub fn timezone(&self) -> &TimezoneSource {
362        &self.timezone
363    }
364
365    /// Return the configured elapsed-time anchor.
366    pub fn elapsed_anchor(&self) -> ElapsedAnchor {
367        self.elapsed
368    }
369}
370
371struct TimestampingIterator<R: std::io::BufRead> {
372    reader: R,
373    clock: Wall,
374    timestamper: Timestamper,
375    program_start: Option<chrono::DateTime<chrono::Utc>>,
376    previous_line_at: Option<chrono::DateTime<chrono::Utc>>,
377}
378
379impl<R: std::io::BufRead> Iterator for TimestampingIterator<R> {
380    type Item = Result<Vec<u8>, std::io::Error>;
381
382    fn next(&mut self) -> Option<Self::Item> {
383        let mut line = Vec::with_capacity(256);
384        match self.reader.read_until(b'\n', &mut line) {
385            Ok(0) => None, // EOF
386            Ok(_) => {
387                let now = self.clock.now();
388                let prog_start = *self.program_start.get_or_insert(now);
389                let prev = *self.previous_line_at.get_or_insert(prog_start);
390
391                let prefix = match self.timestamper.elapsed {
392                    ElapsedAnchor::Absolute => format::format_with(
393                        self.timestamper.format_spec(),
394                        now,
395                        &self.timestamper.timezone,
396                    ),
397                    ElapsedAnchor::SincePreviousLine => {
398                        let delta = (now - prev).to_std().unwrap_or_default();
399                        self.previous_line_at = Some(now);
400                        elapsed_string(self.timestamper.format_spec(), delta)
401                    }
402                    ElapsedAnchor::SinceProgramStart => {
403                        let delta = (now - prog_start).to_std().unwrap_or_default();
404                        elapsed_string(self.timestamper.format_spec(), delta)
405                    }
406                };
407
408                let mut out = Vec::with_capacity(prefix.len() + 2 + line.len());
409                out.extend_from_slice(prefix.as_bytes());
410                out.extend_from_slice(b" ");
411                out.extend_from_slice(&line);
412                Some(Ok(out))
413            }
414            Err(err) => Some(Err(err)),
415        }
416    }
417}
418
419fn elapsed_string(spec: &str, elapsed: std::time::Duration) -> String {
420    let secs = elapsed.as_secs() as i64;
421    let nsecs = elapsed.subsec_nanos();
422    let synthetic = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nsecs)
423        .unwrap_or_else(|| chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
424    format::format_with(spec, synthetic, &TimezoneSource::Utc)
425}
426
427// ───────────────────────── CLI Entry Point ─────────────────────────────────
428
429/// Resolve the runtime clock source. Selection precedence:
430///
431/// 1. `RUSTY_TS_TEST_FIXED_CLOCK=<rfc3339>` env var — test-only deterministic
432///    clock for integration tests. Pins the clock to the parsed instant.
433///    Used by `tests/cli_errors.rs` elapsed-mode tests (T052/T053/T054).
434/// 2. `-m` / `--monotonic` flag → `Monotonic` clock.
435/// 3. Default → `Wall` clock.
436///
437/// Gated behind `cli` because `Wall` / `Monotonic` / `Fixed` constructors
438/// are in `time::clock` which is unconditionally compiled; the env var
439/// reading lives only in the binary entry path.
440#[cfg(feature = "cli")]
441fn resolve_clock(monotonic: bool) -> Box<dyn Clock> {
442    if let Ok(fixed_str) = std::env::var("RUSTY_TS_TEST_FIXED_CLOCK") {
443        if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&fixed_str) {
444            return Box::new(time::clock::Fixed::new(parsed.with_timezone(&chrono::Utc)));
445        }
446        // Malformed env var: fall through to default clock rather than
447        // failing — keeps the env var fully opt-in/test-only.
448    }
449    if monotonic {
450        Box::new(time::clock::Monotonic::new())
451    } else {
452        Box::new(time::clock::Wall)
453    }
454}
455
456/// Binary entry point shared by `src/main.rs` and `src/bin/ts.rs`.
457///
458/// Resolves [`CompatibilityMode`] once at startup from CLI flag → env var →
459/// argv[0] basename per FR-021. Dispatches to the appropriate pipeline
460/// based on the resolved flags (relative mode, elapsed modes, absolute
461/// default). In Strict mode, Rusty-only flags are rejected with a
462/// moreutils-style diagnostic.
463#[cfg(feature = "cli")]
464pub fn run() -> std::process::ExitCode {
465    use clap::Parser;
466    use std::io::{Write, stderr, stdin, stdout};
467
468    let cli = match cli::Cli::try_parse() {
469        Ok(c) => c,
470        Err(err) => err.exit(), // clap handles its own help/version exit codes; never returns
471    };
472
473    // Resolve compatibility mode once at startup (FR-021..023).
474    let argv0 = std::env::args_os().next();
475    let argv0_basename = argv0
476        .as_ref()
477        .and_then(|s| mode::argv0_basename(s.as_os_str()));
478    let env_strict = std::env::var("RUSTY_TS_STRICT").ok();
479    let compat = mode::resolve(
480        cli.explicit_compat_choice(),
481        env_strict.as_deref(),
482        argv0_basename.as_deref(),
483    );
484
485    // In Strict mode, reject Rusty-only flags with the exact moreutils
486    // stderr format (FR-026): `Unknown option: <flag>\nusage: ts [-r] [format]\n`.
487    // Verified byte-equal against captured moreutils output in
488    // tests/compat_strict.rs.
489    if compat == CompatibilityMode::Strict {
490        let bad_flag: Option<&str> = if cli.utc {
491            Some("u")
492        } else if cli.tz.is_some() {
493            Some("tz")
494        } else if cli.subcommand.is_some() {
495            Some("completions")
496        } else {
497            None
498        };
499        if let Some(flag) = bad_flag {
500            let _ = writeln!(stderr(), "Unknown option: {flag}");
501            let _ = writeln!(stderr(), "usage: ts [-r] [format]");
502            return std::process::ExitCode::from(2);
503        }
504    }
505
506    // Defense-in-depth: validate -u + --tz mutex (clap also enforces this).
507    if let Err(err) = cli.validate() {
508        let _ = writeln!(stderr(), "rusty-ts: {err}");
509        return std::process::ExitCode::from(2);
510    }
511
512    // Dispatch subcommands first.
513    if let Some(cli::CliCommand::Completions { shell }) = cli.subcommand {
514        let mut out = stdout().lock();
515        if let Err(err) = completions::emit_completions(shell, &mut out) {
516            if err.kind() == std::io::ErrorKind::BrokenPipe {
517                return std::process::ExitCode::SUCCESS;
518            }
519            let _ = writeln!(stderr(), "rusty-ts: {err}");
520            return std::process::ExitCode::from(1);
521        }
522        return std::process::ExitCode::SUCCESS;
523    }
524
525    // Resolve timezone source.
526    let tz = if cli.utc {
527        TimezoneSource::Utc
528    } else if let Some(name) = &cli.tz {
529        match TimezoneSource::named(name) {
530            Ok(t) => t,
531            Err(err) => {
532                let _ = writeln!(stderr(), "rusty-ts: {err}");
533                return std::process::ExitCode::from(2);
534            }
535        }
536    } else {
537        TimezoneSource::Local
538    };
539
540    // Resolve format spec — positional argument wins over RUSTY_TS_FORMAT.
541    // RUSTY_TS_FORMAT is ignored in Strict mode (FR-027).
542    let format_spec: String = if let Some(spec) = &cli.format {
543        spec.clone()
544    } else if compat == CompatibilityMode::Default {
545        std::env::var("RUSTY_TS_FORMAT")
546            .ok()
547            .filter(|s| !s.is_empty())
548            .unwrap_or_else(|| format::DEFAULT_FORMAT.to_string())
549    } else {
550        format::DEFAULT_FORMAT.to_string()
551    };
552
553    let stdin = stdin();
554    let stdout = stdout();
555    let stdin_locked = stdin.lock();
556    let mut stdout_locked = stdout.lock();
557
558    // Resolve the clock source: -m switches to monotonic, otherwise wall.
559    // Test-only `RUSTY_TS_TEST_FIXED_CLOCK=<rfc3339>` env var pins the clock
560    // to a deterministic instant for integration tests (HINT-003).
561    let clock: Box<dyn Clock> = resolve_clock(cli.monotonic);
562
563    let result: std::io::Result<()> = if cli.relative {
564        let rewriter = relative::RelativeRewriter::for_mode(compat);
565        let cfg = pipeline::RelativeConfig {
566            rewriter: &rewriter,
567            reference: clock.now(),
568        };
569        pipeline::run_relative(stdin_locked, &mut stdout_locked, &cfg)
570    } else {
571        let source = if cli.incremental {
572            PrefixSource::SincePreviousLine
573        } else if cli.since_start {
574            PrefixSource::SinceProgramStart
575        } else {
576            PrefixSource::Absolute
577        };
578        let cfg = PrefixConfig {
579            format: &format_spec,
580            tz: &tz,
581            clock: clock.as_ref(),
582            source,
583        };
584        pipeline::run_prefix(stdin_locked, &mut stdout_locked, &cfg)
585    };
586
587    match result {
588        Ok(()) => std::process::ExitCode::SUCCESS,
589        Err(err) => {
590            let _ = writeln!(stderr(), "rusty-ts: {err}");
591            std::process::ExitCode::from(1)
592        }
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn builder_default_yields_absolute_default_format() {
602        let ts = TimestamperBuilder::new().build().expect("builds");
603        assert_eq!(ts.format_spec(), format::DEFAULT_FORMAT);
604        assert!(matches!(ts.elapsed_anchor(), ElapsedAnchor::Absolute));
605    }
606
607    #[test]
608    fn builder_custom_format_round_trips() {
609        let ts = TimestamperBuilder::new()
610            .format(Format::Strftime("%H:%M:%S".into()))
611            .build()
612            .expect("builds");
613        assert_eq!(ts.format_spec(), "%H:%M:%S");
614    }
615
616    #[test]
617    fn prefix_lines_byte_typed_iterator() {
618        let ts = TimestamperBuilder::new()
619            .format(Format::Strftime("[%H:%M:%S]".into()))
620            .timezone(TimezoneSource::Utc)
621            .build()
622            .expect("builds");
623        let input = std::io::Cursor::new(b"hello\nworld\n".to_vec());
624        let chunks: Vec<Vec<u8>> = ts
625            .prefix_lines(input)
626            .collect::<Result<Vec<_>, _>>()
627            .expect("io ok");
628        assert_eq!(chunks.len(), 2);
629        // Each chunk ends with " hello\n" or " world\n".
630        assert!(chunks[0].ends_with(b" hello\n"), "got {:?}", chunks[0]);
631        assert!(chunks[1].ends_with(b" world\n"), "got {:?}", chunks[1]);
632    }
633
634    #[test]
635    fn prefix_string_lines_utf8_convenience() {
636        let ts = TimestamperBuilder::new()
637            .format(Format::Strftime("[%H:%M:%S]".into()))
638            .timezone(TimezoneSource::Utc)
639            .build()
640            .expect("builds");
641        let lines = vec!["hello\n".to_string(), "world\n".to_string()];
642        let out: Vec<String> = ts.prefix_string_lines(lines).collect();
643        assert_eq!(out.len(), 2);
644        assert!(out[0].ends_with(" hello\n"));
645        assert!(out[1].ends_with(" world\n"));
646    }
647
648    /// Send + !Sync compile-time assertion per `plan.md` AD-008.
649    #[test]
650    fn timestamper_is_send() {
651        fn assert_send<T: Send>() {}
652        assert_send::<Timestamper>();
653    }
654
655    #[test]
656    fn timestamper_builder_is_send_sync() {
657        fn assert_send_sync<T: Send + Sync>() {}
658        assert_send_sync::<TimestamperBuilder>();
659    }
660
661    #[test]
662    fn non_utf8_payload_preserved_through_byte_iterator() {
663        let ts = TimestamperBuilder::new()
664            .format(Format::Strftime("[%H:%M:%S]".into()))
665            .timezone(TimezoneSource::Utc)
666            .build()
667            .expect("builds");
668        // Payload with 0xFF byte (invalid UTF-8).
669        let input: &[u8] = b"hello\xff\nworld\n";
670        let chunks: Vec<Vec<u8>> = ts
671            .prefix_lines(std::io::Cursor::new(input.to_vec()))
672            .collect::<Result<Vec<_>, _>>()
673            .expect("io ok");
674        assert!(
675            chunks[0].contains(&0xFF),
676            "expected 0xFF byte preserved in first chunk; got {:?}",
677            chunks[0],
678        );
679    }
680}