#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "cli")]
pub mod cli;
#[cfg(feature = "cli")]
pub mod compat_matrix;
#[cfg(feature = "cli")]
pub mod completions;
pub mod error;
pub mod mode;
pub mod pipeline;
pub mod relative;
pub mod time;
pub use error::Error;
pub use mode::{CompatibilityMode, ExplicitChoice};
pub use time::tz::TimezoneSource;
use crate::pipeline::{PrefixConfig, PrefixSource};
use crate::time::clock::{Clock, Wall};
use crate::time::format;
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub enum Format {
#[default]
Default,
Strftime(String),
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default)]
pub enum ElapsedAnchor {
#[default]
Absolute,
SincePreviousLine,
SinceProgramStart,
}
#[derive(Debug, Clone, Default)]
pub struct TimestamperBuilder {
format: Format,
utc_requested: bool,
named_tz: Option<String>,
timezone_override: Option<TimezoneSource>,
compat: CompatibilityMode,
elapsed: ElapsedAnchor,
}
impl TimestamperBuilder {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn format(mut self, format: Format) -> Self {
self.format = format;
self
}
#[must_use]
pub fn utc(mut self, utc: bool) -> Self {
self.utc_requested = utc;
self
}
#[must_use]
pub fn tz_name(mut self, name: impl Into<String>) -> Self {
self.named_tz = Some(name.into());
self
}
#[must_use]
pub fn timezone(mut self, tz: TimezoneSource) -> Self {
self.timezone_override = Some(tz);
self
}
#[must_use]
pub fn compat(mut self, mode: CompatibilityMode) -> Self {
self.compat = mode;
self
}
#[must_use]
pub fn elapsed(mut self, anchor: ElapsedAnchor) -> Self {
self.elapsed = anchor;
self
}
pub fn build(self) -> Result<Timestamper, Error> {
if self.utc_requested {
if let Some(name) = &self.named_tz {
return Err(Error::InvalidUtcWithNamedTz { tz: name.clone() });
}
}
let timezone = if let Some(direct) = self.timezone_override {
direct
} else if self.utc_requested {
TimezoneSource::Utc
} else if let Some(name) = self.named_tz {
TimezoneSource::named(&name)?
} else {
TimezoneSource::Local
};
Ok(Timestamper {
format: self.format,
timezone,
compat: self.compat,
elapsed: self.elapsed,
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Timestamper {
format: Format,
timezone: TimezoneSource,
compat: CompatibilityMode,
elapsed: ElapsedAnchor,
}
impl Timestamper {
pub fn prefix_lines<R: std::io::BufRead>(
&self,
reader: R,
) -> impl Iterator<Item = Result<Vec<u8>, std::io::Error>> {
TimestampingIterator {
reader,
clock: Wall,
timestamper: self.clone(),
program_start: None,
previous_line_at: None,
}
}
pub fn prefix_string_lines<I>(&self, lines: I) -> impl Iterator<Item = String>
where
I: IntoIterator<Item = String>,
{
let clock = Wall;
let program_start = clock.now();
let format_spec = self.format_spec().to_owned();
let tz = self.timezone.clone();
let elapsed = self.elapsed;
let mut previous_line_at = program_start;
lines.into_iter().map(move |line| {
let now = clock.now();
let prefix = match elapsed {
ElapsedAnchor::Absolute => format::format_with(&format_spec, now, &tz),
ElapsedAnchor::SincePreviousLine => {
let delta = (now - previous_line_at).to_std().unwrap_or_default();
previous_line_at = now;
elapsed_string(&format_spec, delta)
}
ElapsedAnchor::SinceProgramStart => {
let delta = (now - program_start).to_std().unwrap_or_default();
elapsed_string(&format_spec, delta)
}
};
format!("{prefix} {line}")
})
}
pub fn format_spec(&self) -> &str {
match &self.format {
Format::Default => format::DEFAULT_FORMAT,
Format::Strftime(s) => s.as_str(),
}
}
pub fn compat(&self) -> CompatibilityMode {
self.compat
}
pub fn timezone(&self) -> &TimezoneSource {
&self.timezone
}
pub fn elapsed_anchor(&self) -> ElapsedAnchor {
self.elapsed
}
}
struct TimestampingIterator<R: std::io::BufRead> {
reader: R,
clock: Wall,
timestamper: Timestamper,
program_start: Option<chrono::DateTime<chrono::Utc>>,
previous_line_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl<R: std::io::BufRead> Iterator for TimestampingIterator<R> {
type Item = Result<Vec<u8>, std::io::Error>;
fn next(&mut self) -> Option<Self::Item> {
let mut line = Vec::with_capacity(256);
match self.reader.read_until(b'\n', &mut line) {
Ok(0) => None, Ok(_) => {
let now = self.clock.now();
let prog_start = *self.program_start.get_or_insert(now);
let prev = *self.previous_line_at.get_or_insert(prog_start);
let prefix = match self.timestamper.elapsed {
ElapsedAnchor::Absolute => format::format_with(
self.timestamper.format_spec(),
now,
&self.timestamper.timezone,
),
ElapsedAnchor::SincePreviousLine => {
let delta = (now - prev).to_std().unwrap_or_default();
self.previous_line_at = Some(now);
elapsed_string(self.timestamper.format_spec(), delta)
}
ElapsedAnchor::SinceProgramStart => {
let delta = (now - prog_start).to_std().unwrap_or_default();
elapsed_string(self.timestamper.format_spec(), delta)
}
};
let mut out = Vec::with_capacity(prefix.len() + 2 + line.len());
out.extend_from_slice(prefix.as_bytes());
out.extend_from_slice(b" ");
out.extend_from_slice(&line);
Some(Ok(out))
}
Err(err) => Some(Err(err)),
}
}
}
fn elapsed_string(spec: &str, elapsed: std::time::Duration) -> String {
let secs = elapsed.as_secs() as i64;
let nsecs = elapsed.subsec_nanos();
let synthetic = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, nsecs)
.unwrap_or_else(|| chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap());
format::format_with(spec, synthetic, &TimezoneSource::Utc)
}
#[cfg(feature = "cli")]
fn resolve_clock(monotonic: bool) -> Box<dyn Clock> {
if let Ok(fixed_str) = std::env::var("RUSTY_TS_TEST_FIXED_CLOCK") {
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&fixed_str) {
return Box::new(time::clock::Fixed::new(parsed.with_timezone(&chrono::Utc)));
}
}
if monotonic {
Box::new(time::clock::Monotonic::new())
} else {
Box::new(time::clock::Wall)
}
}
#[cfg(feature = "cli")]
pub fn run() -> std::process::ExitCode {
use clap::Parser;
use std::io::{Write, stderr, stdin, stdout};
let cli = match cli::Cli::try_parse() {
Ok(c) => c,
Err(err) => err.exit(), };
let argv0 = std::env::args_os().next();
let argv0_basename = argv0
.as_ref()
.and_then(|s| mode::argv0_basename(s.as_os_str()));
let env_strict = std::env::var("RUSTY_TS_STRICT").ok();
let compat = mode::resolve(
cli.explicit_compat_choice(),
env_strict.as_deref(),
argv0_basename.as_deref(),
);
if compat == CompatibilityMode::Strict {
let bad_flag: Option<&str> = if cli.utc {
Some("u")
} else if cli.tz.is_some() {
Some("tz")
} else if cli.subcommand.is_some() {
Some("completions")
} else {
None
};
if let Some(flag) = bad_flag {
let _ = writeln!(stderr(), "Unknown option: {flag}");
let _ = writeln!(stderr(), "usage: ts [-r] [format]");
return std::process::ExitCode::from(2);
}
}
if let Err(err) = cli.validate() {
let _ = writeln!(stderr(), "rusty-ts: {err}");
return std::process::ExitCode::from(2);
}
if let Some(cli::CliCommand::Completions { shell }) = cli.subcommand {
let mut out = stdout().lock();
if let Err(err) = completions::emit_completions(shell, &mut out) {
if err.kind() == std::io::ErrorKind::BrokenPipe {
return std::process::ExitCode::SUCCESS;
}
let _ = writeln!(stderr(), "rusty-ts: {err}");
return std::process::ExitCode::from(1);
}
return std::process::ExitCode::SUCCESS;
}
let tz = if cli.utc {
TimezoneSource::Utc
} else if let Some(name) = &cli.tz {
match TimezoneSource::named(name) {
Ok(t) => t,
Err(err) => {
let _ = writeln!(stderr(), "rusty-ts: {err}");
return std::process::ExitCode::from(2);
}
}
} else {
TimezoneSource::Local
};
let format_spec: String = if let Some(spec) = &cli.format {
spec.clone()
} else if compat == CompatibilityMode::Default {
std::env::var("RUSTY_TS_FORMAT")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format::DEFAULT_FORMAT.to_string())
} else {
format::DEFAULT_FORMAT.to_string()
};
let stdin = stdin();
let stdout = stdout();
let stdin_locked = stdin.lock();
let mut stdout_locked = stdout.lock();
let clock: Box<dyn Clock> = resolve_clock(cli.monotonic);
let result: std::io::Result<()> = if cli.relative {
let rewriter = relative::RelativeRewriter::for_mode(compat);
let cfg = pipeline::RelativeConfig {
rewriter: &rewriter,
reference: clock.now(),
};
pipeline::run_relative(stdin_locked, &mut stdout_locked, &cfg)
} else {
let source = if cli.incremental {
PrefixSource::SincePreviousLine
} else if cli.since_start {
PrefixSource::SinceProgramStart
} else {
PrefixSource::Absolute
};
let cfg = PrefixConfig {
format: &format_spec,
tz: &tz,
clock: clock.as_ref(),
source,
};
pipeline::run_prefix(stdin_locked, &mut stdout_locked, &cfg)
};
match result {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
let _ = writeln!(stderr(), "rusty-ts: {err}");
std::process::ExitCode::from(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_default_yields_absolute_default_format() {
let ts = TimestamperBuilder::new().build().expect("builds");
assert_eq!(ts.format_spec(), format::DEFAULT_FORMAT);
assert!(matches!(ts.elapsed_anchor(), ElapsedAnchor::Absolute));
}
#[test]
fn builder_custom_format_round_trips() {
let ts = TimestamperBuilder::new()
.format(Format::Strftime("%H:%M:%S".into()))
.build()
.expect("builds");
assert_eq!(ts.format_spec(), "%H:%M:%S");
}
#[test]
fn prefix_lines_byte_typed_iterator() {
let ts = TimestamperBuilder::new()
.format(Format::Strftime("[%H:%M:%S]".into()))
.timezone(TimezoneSource::Utc)
.build()
.expect("builds");
let input = std::io::Cursor::new(b"hello\nworld\n".to_vec());
let chunks: Vec<Vec<u8>> = ts
.prefix_lines(input)
.collect::<Result<Vec<_>, _>>()
.expect("io ok");
assert_eq!(chunks.len(), 2);
assert!(chunks[0].ends_with(b" hello\n"), "got {:?}", chunks[0]);
assert!(chunks[1].ends_with(b" world\n"), "got {:?}", chunks[1]);
}
#[test]
fn prefix_string_lines_utf8_convenience() {
let ts = TimestamperBuilder::new()
.format(Format::Strftime("[%H:%M:%S]".into()))
.timezone(TimezoneSource::Utc)
.build()
.expect("builds");
let lines = vec!["hello\n".to_string(), "world\n".to_string()];
let out: Vec<String> = ts.prefix_string_lines(lines).collect();
assert_eq!(out.len(), 2);
assert!(out[0].ends_with(" hello\n"));
assert!(out[1].ends_with(" world\n"));
}
#[test]
fn timestamper_is_send() {
fn assert_send<T: Send>() {}
assert_send::<Timestamper>();
}
#[test]
fn timestamper_builder_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TimestamperBuilder>();
}
#[test]
fn non_utf8_payload_preserved_through_byte_iterator() {
let ts = TimestamperBuilder::new()
.format(Format::Strftime("[%H:%M:%S]".into()))
.timezone(TimezoneSource::Utc)
.build()
.expect("builds");
let input: &[u8] = b"hello\xff\nworld\n";
let chunks: Vec<Vec<u8>> = ts
.prefix_lines(std::io::Cursor::new(input.to_vec()))
.collect::<Result<Vec<_>, _>>()
.expect("io ok");
assert!(
chunks[0].contains(&0xFF),
"expected 0xFF byte preserved in first chunk; got {:?}",
chunks[0],
);
}
}