use chrono::Local;
use chrono::format::Item;
use chrono::format::strftime::StrftimeItems;
use std::io::{BufRead, Write};
use std::time::{Duration, Instant};
pub fn validate_format(fmt: &str) -> Result<(), String> {
let has_error = StrftimeItems::new(fmt).any(|item| matches!(item, Item::Error));
if has_error {
Err(format!("invalid format string: {:?}", fmt))
} else {
Ok(())
}
}
pub fn format_now(fmt: &str) -> String {
Local::now().format(fmt).to_string()
}
pub fn format_elapsed(duration: Duration) -> String {
let total_millis = duration.as_millis();
let millis = total_millis % 1_000;
let total_seconds = total_millis / 1_000;
let seconds = total_seconds % 60;
let total_minutes = total_seconds / 60;
let minutes = total_minutes % 60;
let hours = total_minutes / 60;
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
}
pub fn format_incremental(last: &mut Option<Instant>) -> String {
let now = Instant::now();
let elapsed = match *last {
Some(last) => now.duration_since(last),
None => Duration::ZERO,
};
*last = Some(now);
format_elapsed(elapsed)
}
pub fn prepend_timestamp(timestamp: &str, line: &str) -> String {
format!("{} {}", timestamp, line)
}
pub fn process_lines<R, W, F>(
reader: R,
writer: &mut W,
mut get_timestamp: F,
) -> std::io::Result<()>
where
R: BufRead,
W: Write,
F: FnMut() -> String,
{
for line in reader.lines() {
let line = line?;
let output = prepend_timestamp(&get_timestamp(), &line);
writeln!(writer, "{}", output)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn prepends_timestamp_to_normal_line() {
let result = prepend_timestamp("2026-05-01 14:32:01", "hello world");
assert_eq!(result, "2026-05-01 14:32:01 hello world");
}
#[test]
fn prepends_timestamp_to_empty_line() {
let result = prepend_timestamp("2026-05-01 14:32:01", "");
assert_eq!(result, "2026-05-01 14:32:01 ");
}
#[test]
fn preserves_leading_whitespace_in_line() {
let result = prepend_timestamp("2026-05-01 14:32:01", " indented");
assert_eq!(result, "2026-05-01 14:32:01 indented");
}
#[test]
fn process_lines_prepends_each_line() {
let input = b"line one\nline two\nline three\n";
let mut output: Vec<u8> = Vec::new();
process_lines(input.as_ref(), &mut output, || {
"2026-05-01 14:32:01".to_string()
})
.unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(
result,
"2026-05-01 14:32:01 line one\n\
2026-05-01 14:32:01 line two\n\
2026-05-01 14:32:01 line three\n"
);
}
#[test]
fn process_lines_single_line_no_trailing_newline() {
let input = b"only line";
let mut output: Vec<u8> = Vec::new();
process_lines(input.as_ref(), &mut output, || {
"2026-05-01 14:32:01".to_string()
})
.unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "2026-05-01 14:32:01 only line\n");
}
#[test]
fn process_lines_empty_input_produces_no_output() {
let input = b"";
let mut output: Vec<u8> = Vec::new();
process_lines(input.as_ref(), &mut output, || {
"2026-05-01 14:32:01".to_string()
})
.unwrap();
assert!(output.is_empty());
}
#[test]
fn process_lines_returns_err_on_invalid_utf8() {
let input: &[u8] = b"valid\n\xff\n";
let mut output: Vec<u8> = Vec::new();
let result = process_lines(input, &mut output, || "T".to_string());
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
#[test]
fn process_lines_returns_err_on_broken_pipe() {
struct BrokenPipeWriter;
impl Write for BrokenPipeWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let input = b"hello\n";
let mut writer = BrokenPipeWriter;
let result = process_lines(input.as_ref(), &mut writer, || "T".to_string());
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn validate_format_accepts_default_format() {
assert!(validate_format("%Y-%m-%d %H:%M:%S").is_ok());
}
#[test]
fn validate_format_accepts_custom_bracket_format() {
assert!(validate_format("[%H:%M:%S]").is_ok());
}
#[test]
fn validate_format_rejects_bare_percent_at_end() {
assert!(validate_format("%").is_err());
}
#[test]
fn validate_format_rejects_unknown_specifier() {
assert!(validate_format("%Q").is_err());
}
#[test]
fn format_now_default_format_has_correct_length() {
let result = format_now("%Y-%m-%d %H:%M:%S");
assert_eq!(result.len(), 19, "got: {}", result);
}
#[test]
fn format_now_custom_format_applied() {
let result = format_now("[%H:%M:%S]");
assert_eq!(result.len(), 10, "got: {}", result);
assert!(result.starts_with('['));
assert!(result.ends_with(']'));
}
#[test]
fn process_lines_calls_get_timestamp_once_per_line() {
let input = b"a\nb\nc\n";
let mut output: Vec<u8> = Vec::new();
let call_count = std::cell::Cell::new(0usize);
process_lines(input.as_ref(), &mut output, || {
call_count.set(call_count.get() + 1);
format!("T{}", call_count.get())
})
.unwrap();
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "T1 a\nT2 b\nT3 c\n");
assert_eq!(call_count.get(), 3);
}
#[test]
fn format_elapsed_duration_is_zero() {
assert_eq!(format_elapsed(Duration::ZERO), "00:00:00.000");
}
#[test]
fn format_elapsed_subsecond_duration() {
assert_eq!(format_elapsed(Duration::from_millis(42)), "00:00:00.042");
}
#[test]
fn format_elapsed_one_second_duration() {
assert_eq!(format_elapsed(Duration::from_secs(1)), "00:00:01.000");
}
#[test]
fn format_elapsed_one_hour_duration() {
assert_eq!(format_elapsed(Duration::from_secs(3600)), "01:00:00.000");
}
#[test]
fn format_elapsed_edge_cases() {
assert_eq!(format_elapsed(Duration::from_secs(60)), "00:01:00.000");
assert_eq!(format_elapsed(Duration::from_secs(86400)), "24:00:00.000");
}
#[test]
fn format_incremental_first_line_is_zero() {
let mut last = None;
assert_eq!(format_incremental(&mut last), "00:00:00.000");
assert!(last.is_some());
}
#[test]
fn format_incremental_second_call_returns_elapsed_time() {
let mut last = Some(Instant::now() - Duration::from_millis(500));
let result = format_incremental(&mut last);
assert!(
result >= "00:00:00.500".to_string(),
"expected >= 00:00:00.500, got {}",
result
);
}
#[test]
fn format_incremental_resets_last_after_each_call() {
let mut last: Option<Instant> = None;
assert_eq!(format_incremental(&mut last), "00:00:00.000");
let after_first = last.unwrap();
let result = format_incremental(&mut last);
let after_second = last.unwrap();
assert!(
after_second > after_first,
"last should be reset after each call"
);
assert!(result < "00:00:01.000".to_string(), "got: {}", result);
}
}