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}