wavepeek 0.4.0

Command-line tool for RTL waveform inspection with deterministic machine-friendly output.
Documentation
use crate::error::WavepeekError;
use crate::waveform::WaveformMetadata;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ParsedTime {
    pub(crate) value: u64,
    pub(crate) unit: TimeUnit,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TimeUnit {
    Zs,
    As,
    Fs,
    Ps,
    Ns,
    Us,
    Ms,
    S,
}

impl TimeUnit {
    fn suffix(self) -> &'static str {
        match self {
            Self::Zs => "zs",
            Self::As => "as",
            Self::Fs => "fs",
            Self::Ps => "ps",
            Self::Ns => "ns",
            Self::Us => "us",
            Self::Ms => "ms",
            Self::S => "s",
        }
    }

    fn multiplier_in_zeptoseconds(self) -> u128 {
        match self {
            Self::Zs => 1,
            Self::As => 1_000,
            Self::Fs => 1_000_000,
            Self::Ps => 1_000_000_000,
            Self::Ns => 1_000_000_000_000,
            Self::Us => 1_000_000_000_000_000,
            Self::Ms => 1_000_000_000_000_000_000,
            Self::S => 1_000_000_000_000_000_000_000,
        }
    }

    fn parse(token: &str) -> Option<Self> {
        match token {
            "zs" => Some(Self::Zs),
            "as" => Some(Self::As),
            "fs" => Some(Self::Fs),
            "ps" => Some(Self::Ps),
            "ns" => Some(Self::Ns),
            "us" => Some(Self::Us),
            "ms" => Some(Self::Ms),
            "s" => Some(Self::S),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DumpTimeContext {
    pub(crate) dump_tick: ParsedTime,
    pub(crate) dump_tick_zs: u128,
    pub(crate) dump_start_zs: u128,
    pub(crate) dump_end_zs: u128,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TimeValidationError {
    RequiresUnits,
    InvalidToken,
    TooLarge,
    OutOfBounds,
    NotAligned,
    RawOutOfRange,
}

pub(crate) fn parse_time_token(token: &str) -> Option<ParsedTime> {
    let split_at = token.find(|ch: char| !ch.is_ascii_digit())?;
    if split_at == 0 || split_at >= token.len() {
        return None;
    }

    let value = token[..split_at].parse::<u64>().ok()?;
    let unit = TimeUnit::parse(&token[split_at..])?;
    Some(ParsedTime { value, unit })
}

pub(crate) fn as_zeptoseconds(time: ParsedTime) -> Option<u128> {
    u128::from(time.value).checked_mul(time.unit.multiplier_in_zeptoseconds())
}

pub(crate) fn ensure_non_zero_dump_tick(dump_tick_zs: u128) -> Result<(), WavepeekError> {
    if dump_tick_zs == 0 {
        return Err(WavepeekError::Internal(
            "waveform metadata time_unit must be non-zero".to_string(),
        ));
    }
    Ok(())
}

pub(crate) fn format_raw_timestamp(
    raw_time: u64,
    time_unit: ParsedTime,
) -> Result<String, WavepeekError> {
    let normalized = raw_time.checked_mul(time_unit.value).ok_or_else(|| {
        WavepeekError::Internal("normalized time overflow while formatting timestamp".to_string())
    })?;
    Ok(format!("{normalized}{}", time_unit.unit.suffix()))
}

pub(crate) fn parse_dump_time_context(
    metadata: &WaveformMetadata,
) -> Result<DumpTimeContext, WavepeekError> {
    let dump_tick = parse_time_token(metadata.time_unit.as_str()).ok_or_else(|| {
        WavepeekError::Internal(format!(
            "waveform metadata contains invalid time_unit '{}': expected <integer><unit>",
            metadata.time_unit
        ))
    })?;
    let dump_tick_zs = as_zeptoseconds(dump_tick).ok_or_else(|| {
        WavepeekError::Internal(
            "waveform metadata time_unit overflowed during conversion".to_string(),
        )
    })?;
    ensure_non_zero_dump_tick(dump_tick_zs)?;

    let dump_start = parse_time_token(metadata.time_start.as_str()).ok_or_else(|| {
        WavepeekError::Internal(format!(
            "waveform metadata contains invalid time_start '{}': expected <integer><unit>",
            metadata.time_start
        ))
    })?;
    let dump_start_zs = as_zeptoseconds(dump_start).ok_or_else(|| {
        WavepeekError::Internal(
            "waveform metadata time_start overflowed during conversion".to_string(),
        )
    })?;

    let dump_end = parse_time_token(metadata.time_end.as_str()).ok_or_else(|| {
        WavepeekError::Internal(format!(
            "waveform metadata contains invalid time_end '{}': expected <integer><unit>",
            metadata.time_end
        ))
    })?;
    let dump_end_zs = as_zeptoseconds(dump_end).ok_or_else(|| {
        WavepeekError::Internal(
            "waveform metadata time_end overflowed during conversion".to_string(),
        )
    })?;

    Ok(DumpTimeContext {
        dump_tick,
        dump_tick_zs,
        dump_start_zs,
        dump_end_zs,
    })
}

pub(crate) fn validate_time_token_to_raw(
    token: &str,
    context: DumpTimeContext,
    require_units: bool,
) -> Result<u64, TimeValidationError> {
    if require_units && token.chars().all(|ch| ch.is_ascii_digit()) {
        return Err(TimeValidationError::RequiresUnits);
    }

    let parsed = parse_time_token(token).ok_or(TimeValidationError::InvalidToken)?;
    let parsed_zs = as_zeptoseconds(parsed).ok_or(TimeValidationError::TooLarge)?;

    if parsed_zs < context.dump_start_zs || parsed_zs > context.dump_end_zs {
        return Err(TimeValidationError::OutOfBounds);
    }

    if parsed_zs % context.dump_tick_zs != 0 {
        return Err(TimeValidationError::NotAligned);
    }

    let raw = parsed_zs / context.dump_tick_zs;
    u64::try_from(raw).map_err(|_| TimeValidationError::RawOutOfRange)
}

#[cfg(test)]
mod tests {
    use super::{
        DumpTimeContext, ParsedTime, TimeUnit, TimeValidationError, as_zeptoseconds,
        ensure_non_zero_dump_tick, format_raw_timestamp, parse_dump_time_context, parse_time_token,
        validate_time_token_to_raw,
    };
    use crate::waveform::WaveformMetadata;

    fn metadata() -> WaveformMetadata {
        WaveformMetadata {
            time_unit: "1ns".to_string(),
            time_start: "0ns".to_string(),
            time_end: "10ns".to_string(),
        }
    }

    #[test]
    fn parse_time_token_requires_integer_and_unit() {
        assert_eq!(
            parse_time_token("10ns"),
            Some(ParsedTime {
                value: 10,
                unit: TimeUnit::Ns
            })
        );
        assert_eq!(parse_time_token("100"), None);
        assert_eq!(parse_time_token("ns"), None);
        assert_eq!(parse_time_token("10NS"), None);
    }

    #[test]
    fn zeptoseconds_conversion_supports_cross_unit_comparison() {
        let one_ns = as_zeptoseconds(ParsedTime {
            value: 1,
            unit: TimeUnit::Ns,
        })
        .expect("1ns should convert");
        let thousand_ps = as_zeptoseconds(ParsedTime {
            value: 1000,
            unit: TimeUnit::Ps,
        })
        .expect("1000ps should convert");

        assert_eq!(one_ns, thousand_ps);
    }

    #[test]
    fn raw_timestamp_formatting_uses_dump_time_unit() {
        let formatted = format_raw_timestamp(
            10,
            ParsedTime {
                value: 1,
                unit: TimeUnit::Ns,
            },
        )
        .expect("formatting should succeed");
        assert_eq!(formatted, "10ns");

        let formatted = format_raw_timestamp(
            3,
            ParsedTime {
                value: 10,
                unit: TimeUnit::Ps,
            },
        )
        .expect("formatting should succeed");
        assert_eq!(formatted, "30ps");
    }

    #[test]
    fn dump_tick_must_be_non_zero() {
        let error = ensure_non_zero_dump_tick(0).expect_err("zero tick must fail");
        assert_eq!(
            error.to_string(),
            "error: internal: waveform metadata time_unit must be non-zero"
        );

        ensure_non_zero_dump_tick(1).expect("non-zero tick must pass");
    }

    #[test]
    fn parse_dump_time_context_extracts_dump_bounds_and_tick() {
        let context = parse_dump_time_context(&metadata()).expect("metadata should parse");

        assert_eq!(
            context.dump_tick,
            ParsedTime {
                value: 1,
                unit: TimeUnit::Ns,
            }
        );
        assert_eq!(
            context.dump_tick_zs, 1_000_000_000_000,
            "1ns should convert to zeptoseconds"
        );
        assert_eq!(context.dump_start_zs, 0);
        assert_eq!(context.dump_end_zs, 10_000_000_000_000);
    }

    #[test]
    fn validate_time_token_to_raw_rejects_requires_units_and_invalid_tokens() {
        let context = parse_dump_time_context(&metadata()).expect("metadata should parse");

        assert_eq!(
            validate_time_token_to_raw("10", context, true),
            Err(TimeValidationError::RequiresUnits)
        );
        assert_eq!(
            validate_time_token_to_raw("1.5ns", context, true),
            Err(TimeValidationError::InvalidToken)
        );
    }

    #[test]
    fn validate_time_token_to_raw_rejects_too_large_bounds_and_misalignment() {
        let context = parse_dump_time_context(&metadata()).expect("metadata should parse");
        let too_large_token = format!("{}s", u64::MAX);

        assert_eq!(
            validate_time_token_to_raw(too_large_token.as_str(), context, false),
            Err(TimeValidationError::TooLarge)
        );
        assert_eq!(
            validate_time_token_to_raw("11ns", context, false),
            Err(TimeValidationError::OutOfBounds)
        );
        assert_eq!(
            validate_time_token_to_raw("15ps", context, false),
            Err(TimeValidationError::NotAligned)
        );
    }

    #[test]
    fn validate_time_token_to_raw_rejects_raw_range_overflow() {
        let token = format!("{}ns", u64::MAX);
        let end_zs = as_zeptoseconds(ParsedTime {
            value: u64::MAX,
            unit: TimeUnit::Ns,
        })
        .expect("raw-overflow test token should convert to zeptoseconds");
        let context = DumpTimeContext {
            dump_tick: ParsedTime {
                value: 1,
                unit: TimeUnit::Zs,
            },
            dump_tick_zs: 1,
            dump_start_zs: 0,
            dump_end_zs: end_zs,
        };

        assert_eq!(
            validate_time_token_to_raw(token.as_str(), context, false),
            Err(TimeValidationError::RawOutOfRange)
        );
    }
}