proto-types 0.2.2

⚙️ Implementations for various common protobuf types.
Documentation
use crate::{Duration, Timestamp};

#[cfg(not(feature = "chrono"))]
impl crate::Timestamp {
	/// Returns the timestamp in YYYY-MM-DD format.
	/// The same method, with the `chrono` feature, allows for custom formatting.
	pub fn format(&self) -> crate::String {
		use crate::ToString;

		self.to_string()
	}
}

#[cfg(feature = "chrono")]
mod chrono {
	use chrono::Utc;

	use crate::{String, Timestamp, ToString, timestamp::TimestampError};

	impl Timestamp {
		/// Converts this timestamp into a [`chrono::DateTime<Utc>`] struct and calls .format on it with the string argument being given.
		pub fn format(&self, string: &str) -> Result<String, TimestampError> {
			let chrono_timestamp: chrono::DateTime<Utc> = (*self).try_into()?;

			Ok(chrono_timestamp.format(string).to_string())
		}

		/// Converts this [`Timestamp`] instance to chrono::[`DateTime`](::chrono::DateTime) with [`chrono::Utc`].
		#[inline]
		pub fn as_datetime_utc(&self) -> Result<chrono::DateTime<Utc>, TimestampError> {
			(*self).try_into()
		}
	}
}

impl Timestamp {
	/// Creates a new instance.
	#[must_use]
	#[inline]
	pub const fn new(seconds: i64, nanos: i32) -> Self {
		Self { seconds, nanos }
	}

	/// Creates a new [`Timestamp`] from the given number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z).
	///
	/// This function handles both positive (post-1970) and negative (pre-1970) timestamps.
	/// It ensures that the resulting timestamp is **normalized** according to Protobuf specifications,
	/// meaning the `nanos` field will always be non-negative (0 to 999,999,999), adjusting the `seconds`
	/// field accordingly.
	///
	/// # Examples
	///
	/// ```
	/// use proto_types::Timestamp;
	///
	/// // Post-1970 (1.5 seconds)
	/// let ts = Timestamp::from_unix_millis(1_500);
	/// assert_eq!(ts.seconds, 1);
	/// assert_eq!(ts.nanos, 500_000_000);
	///
	/// // Pre-1970 (-100ms)
	/// // Represented as: The second *before* epoch (-1), plus 900ms forward.
	/// let ts = Timestamp::from_unix_millis(-100);
	/// assert_eq!(ts.seconds, -1);
	/// assert_eq!(ts.nanos, 900_000_000);
	/// ```
	#[must_use]
	pub fn from_unix_millis(millis: i64) -> Self {
		let seconds = millis / 1000;

		// SAFETY: millis % 1000 is max 999. 999 * 1_000_000 fits in i32.
		#[allow(clippy::cast_possible_truncation)]
		let nanos = ((millis % 1000) * 1_000_000) as i32;

		let ts = Self { seconds, nanos };

		ts.normalized()
	}

	/// Calculates the total number of milliseconds since the Unix epoch (1970-01-01T00:00:00Z).
	///
	/// This method automatically normalizes the timestamp before calculation to ensure correctness
	/// for timestamps with negative nanoseconds (pre-1970 representation) or denormalized values.
	///
	/// Returns `None` if the calculation would overflow the [`i64`] range,
	/// or if the timestamp itself cannot be normalized.
	///
	/// # Examples
	///
	/// ```
	/// use proto_types::Timestamp;
	///
	/// // 1.5 seconds -> 1500 ms
	/// let ts = Timestamp { seconds: 1, nanos: 500_000_000 };
	/// assert_eq!(ts.checked_total_i64_millis(), Some(1_500));
	///
	/// // Pre-1970: -1s + 900ms = -100ms
	/// let ts = Timestamp { seconds: -1, nanos: 900_000_000 };
	/// assert_eq!(ts.checked_total_i64_millis(), Some(-100));
	///
	/// // Overflow check
	/// let ts = Timestamp { seconds: i64::MAX, nanos: 0 };
	/// assert_eq!(ts.checked_total_i64_millis(), None);
	/// ```
	#[must_use]
	pub fn checked_total_i64_millis(&self) -> Option<i64> {
		let ts = (*self).try_normalize().ok()?;

		let seconds_part = ts.seconds.checked_mul(1000)?;
		let nanos_part = i64::from(ts.nanos / 1_000_000);

		seconds_part.checked_add(nanos_part)
	}
}

#[cfg(all(not(feature = "std"), feature = "chrono-wasm"))]
impl Timestamp {
	/// Returns the current timestamp.
	#[must_use]
	#[inline]
	pub fn now() -> Self {
		::chrono::Utc::now().into()
	}
}

#[cfg(feature = "std")]
impl Timestamp {
	/// Returns the current timestamp.
	#[must_use]
	#[inline]
	pub fn now() -> Self {
		std::time::SystemTime::now().into()
	}
}

#[cfg(any(feature = "std", feature = "chrono-wasm"))]
impl Timestamp {
	/// Checks whether the Timestamp instance is within the indicated range (positive or negative) from now.
	#[must_use]
	#[inline]
	pub fn is_within_range_from_now(&self, range: Duration) -> bool {
		let now = Self::now();

		(now + range) >= *self && (now - range) <= *self
	}

	/// Checks whether the Timestamp instance is within the indicated range in the future.
	#[must_use]
	#[inline]
	pub fn is_within_future_range(&self, range: Duration) -> bool {
		let now = Self::now();
		let max = now + range;

		*self <= max && *self >= now
	}

	/// Checks whether the Timestamp instance is within the indicated range in the past.
	#[must_use]
	#[inline]
	pub fn is_within_past_range(&self, range: Duration) -> bool {
		let now = Self::now();
		let min = now - range;

		*self >= min && *self <= now
	}

	/// Returns `true` if the timestamp is in the future.
	#[must_use]
	#[inline]
	pub fn is_future(&self) -> bool {
		*self > Self::now()
	}

	/// Returns `true` if the timestamp is in the past.
	#[must_use]
	#[inline]
	pub fn is_past(&self) -> bool {
		*self < Self::now()
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	fn offset_seconds(base: &Timestamp, s: i64) -> Timestamp {
		Timestamp {
			seconds: base.seconds + s,
			nanos: base.nanos,
		}
	}

	#[test]
	fn test_is_future() {
		let now = Timestamp::now();

		let future_point = offset_seconds(&now, 5);
		let past_point = offset_seconds(&now, -5);

		assert!(future_point.is_future(), "T + 5s should be in the future");
		assert!(
			!past_point.is_future(),
			"T - 5s should NOT be in the future"
		);
	}

	#[test]
	fn test_is_past() {
		let now = Timestamp::now();

		let future_point = offset_seconds(&now, 5);
		let past_point = offset_seconds(&now, -5);

		assert!(past_point.is_past(), "T - 5s should be in the past");
		assert!(!future_point.is_past(), "T + 5s should NOT be in the past");
	}

	#[test]
	fn test_is_within_future_range() {
		let now = Timestamp::now();
		let range = Duration::new(10, 0);

		let inside = offset_seconds(&now, 5);
		assert!(
			inside.is_within_future_range(range),
			"5s is within 10s range"
		);

		let outside_far = offset_seconds(&now, 15);
		assert!(
			!outside_far.is_within_future_range(range),
			"15s is outside 10s range"
		);

		let outside_past = offset_seconds(&now, -1);
		assert!(
			!outside_past.is_within_future_range(range),
			"Past value is not in future range"
		);
	}

	#[test]
	fn test_is_within_past_range() {
		let now = Timestamp::now();
		let range = Duration::new(10, 0);

		let inside = offset_seconds(&now, -5);
		assert!(
			inside.is_within_past_range(range),
			"-5s is within 10s past range"
		);

		let outside_old = offset_seconds(&now, -15);
		assert!(
			!outside_old.is_within_past_range(range),
			"-15s is too old for 10s range"
		);

		let outside_future = offset_seconds(&now, 1);
		assert!(
			!outside_future.is_within_past_range(range),
			"Future value is not in past range"
		);
	}

	#[test]
	fn test_from_unix_millis_positive() {
		let ts = Timestamp::from_unix_millis(1_000);
		assert_eq!(ts.seconds, 1);
		assert_eq!(ts.nanos, 0);

		let ts = Timestamp::from_unix_millis(1_500);
		assert_eq!(ts.seconds, 1);
		assert_eq!(ts.nanos, 500_000_000);
	}

	#[test]
	fn test_from_unix_millis_zero() {
		let ts = Timestamp::from_unix_millis(0);
		assert_eq!(ts.seconds, 0);
		assert_eq!(ts.nanos, 0);
	}

	#[test]
	fn test_from_unix_millis_negative() {
		let ts = Timestamp::from_unix_millis(-1);
		assert_eq!(ts.seconds, -1);
		assert_eq!(ts.nanos, 999_000_000);

		let ts = Timestamp::from_unix_millis(-100);
		assert_eq!(ts.seconds, -1);
		assert_eq!(ts.nanos, 900_000_000);

		let ts = Timestamp::from_unix_millis(-1_500);
		assert_eq!(ts.seconds, -2);
		assert_eq!(ts.nanos, 500_000_000);
	}

	#[test]
	fn test_round_trip_millis() {
		let inputs = std::vec![0, 100, -100, 1_500, -1_500, 999, -999];

		for input in inputs {
			let ts = Timestamp::from_unix_millis(input);
			let result = ts.checked_total_i64_millis().unwrap();
			assert_eq!(input, result, "Round trip failed for {input}ms");
		}
	}

	#[test]
	fn test_total_millis_basic() {
		let ts = Timestamp {
			seconds: 1,
			nanos: 500_000_000,
		};
		assert_eq!(ts.checked_total_i64_millis(), Some(1_500));
	}

	#[test]
	fn test_total_millis_negative_normalization() {
		// Checks that normalize() is called internally before math happens
		let ts = Timestamp {
			seconds: -1,
			nanos: 500_000_000,
		};
		assert_eq!(ts.checked_total_i64_millis(), Some(-500));
	}

	#[test]
	fn test_total_millis_overflow() {
		let ts = Timestamp {
			seconds: i64::MAX,
			nanos: 0,
		};
		assert_eq!(ts.checked_total_i64_millis(), None);

		let ts = Timestamp {
			seconds: i64::MIN,
			nanos: 0,
		};
		assert_eq!(ts.checked_total_i64_millis(), None);
	}

	#[test]
	fn test_total_millis_boundary() {
		let max_safe_seconds = i64::MAX / 1000;

		let ts = Timestamp {
			seconds: max_safe_seconds,
			nanos: 0,
		};
		assert!(ts.checked_total_i64_millis().is_some());

		let ts_overflow = Timestamp {
			seconds: max_safe_seconds + 1,
			nanos: 0,
		};
		assert!(ts_overflow.checked_total_i64_millis().is_none());
	}
}