textfile-metrics 0.1.0

Non-blocking Prometheus textfile metrics writer with Counter and Gauge helpers
Documentation
// Copyright (c) Ted Kaplan. All Rights Reserved.
// SPDX-License-Identifier: MIT

//! Metric types: Counter and Gauge.

use std::{cmp::Ordering, fmt};

use crate::labels::Labels;

/// Metric type enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetricType {
    /// Counter (monotonically increasing).
    Counter,
    /// Gauge (arbitrary value).
    Gauge,
}

impl fmt::Display for MetricType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MetricType::Counter => write!(f, "counter"),
            MetricType::Gauge => write!(f, "gauge"),
        }
    }
}

/// A Counter metric - monotonically increasing value.
#[derive(Debug, Clone)]
pub struct Counter {
    /// Metric name (e.g., "requests_total").
    pub name: String,
    /// Labels for the metric.
    pub labels: Labels,
    /// Current counter value.
    pub value: f64,
}

impl Counter {
    /// Create a new counter.
    pub fn new(name: impl Into<String>, labels: Labels, value: f64) -> Self {
        Self {
            name: name.into(),
            labels,
            value,
        }
    }

    /// Increment the counter.
    pub fn increment(&mut self, delta: f64) {
        self.value += delta;
    }

    /// Get the counter value.
    pub fn value(&self) -> f64 {
        self.value
    }
}

/// A Gauge metric - arbitrary numeric value.
#[derive(Debug, Clone)]
pub struct Gauge {
    /// Metric name (e.g., "temperature_celsius").
    pub name: String,
    /// Labels for the metric.
    pub labels: Labels,
    /// Current gauge value.
    pub value: f64,
}

impl Gauge {
    /// Create a new gauge.
    pub fn new(name: impl Into<String>, labels: Labels, value: f64) -> Self {
        Self {
            name: name.into(),
            labels,
            value,
        }
    }

    /// Set the gauge value.
    pub fn set(&mut self, value: f64) {
        self.value = value;
    }

    /// Get the gauge value.
    pub fn value(&self) -> f64 {
        self.value
    }
}

/// A Prometheus metric with type information.
#[derive(Debug, Clone)]
pub(crate) struct PrometheusMetric {
    /// Metric name.
    pub name: String,
    /// Metric type (COUNTER or GAUGE).
    pub metric_type: MetricType,
    /// Labels.
    pub labels: Labels,
    /// Metric value.
    pub value: f64,
    /// Optional timestamp (Unix milliseconds).
    pub timestamp: Option<i64>,
}

impl PrometheusMetric {
    /// Create a new Prometheus metric.
    pub(crate) fn new(
        name: impl Into<String>,
        metric_type: MetricType,
        labels: Labels,
        value: f64,
    ) -> Self {
        Self {
            name: name.into(),
            metric_type,
            labels,
            value,
            timestamp: None,
        }
    }

    /// Set an optional timestamp (Unix milliseconds).
    #[allow(dead_code)]
    pub(crate) fn with_timestamp(mut self, timestamp: i64) -> Self {
        self.timestamp = Some(timestamp);
        self
    }

    /// Format as Prometheus textfile format line.
    ///
    /// Format: `metric_name{labels} value [timestamp]`
    pub(crate) fn to_prometheus_line(&self) -> String {
        let labels_str = self.labels.to_string();
        let full_name = if labels_str.is_empty() {
            self.name.clone()
        } else {
            format!("{}{}", self.name, labels_str)
        };

        match self.timestamp {
            Some(ts) => format!("{} {} {}", full_name, self.value, ts),
            None => format!("{} {}", full_name, self.value),
        }
    }

    /// Format as Prometheus TYPE declaration.
    ///
    /// Format: `# TYPE metric_name counter|gauge`
    #[allow(dead_code)]
    pub(crate) fn type_declaration(&self) -> String {
        format!("# TYPE {} {}", self.name, self.metric_type)
    }

    /// Check if this metric has valid value (not NaN or Inf).
    pub(crate) fn is_valid(&self) -> bool {
        self.value.is_finite()
    }
}

impl PartialEq for PrometheusMetric {
    fn eq(&self, other: &Self) -> bool {
        self.name == other.name
            && self.metric_type == other.metric_type
            && self.labels == other.labels
            && (self.value - other.value).abs() < 1e-10
    }
}

impl Eq for PrometheusMetric {}

impl PartialOrd for PrometheusMetric {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for PrometheusMetric {
    fn cmp(&self, other: &Self) -> Ordering {
        self.name.cmp(&other.name).then_with(|| {
            self.metric_type
                .to_string()
                .cmp(&other.metric_type.to_string())
        })
    }
}

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

    #[test]
    fn test_counter_new() {
        let counter = Counter::new("test_counter", Labels::new(), 10.0);
        assert_eq!(counter.value(), 10.0);
    }

    #[test]
    fn test_counter_increment() {
        let mut counter = Counter::new("test_counter", Labels::new(), 10.0);
        counter.increment(5.0);
        assert_eq!(counter.value(), 15.0);
    }

    #[test]
    fn test_gauge_new() {
        let gauge = Gauge::new("test_gauge", Labels::new(), 42.5);
        assert_eq!(gauge.value(), 42.5);
    }

    #[test]
    fn test_gauge_set() {
        let mut gauge = Gauge::new("test_gauge", Labels::new(), 42.5);
        gauge.set(99.9);
        assert_eq!(gauge.value(), 99.9);
    }

    #[test]
    fn test_prometheus_metric_line_no_labels() {
        let metric = PrometheusMetric::new("test_metric", MetricType::Gauge, Labels::new(), 42.0);
        assert_eq!(metric.to_prometheus_line(), "test_metric 42");
    }

    #[test]
    fn test_prometheus_metric_line_with_labels() {
        let labels = Labels::from(vec![("method".to_string(), "GET".to_string())]);
        let metric = PrometheusMetric::new("requests_total", MetricType::Counter, labels, 100.0);
        let line = metric.to_prometheus_line();

        assert!(line.contains("requests_total{"));
        assert!(line.contains("method=\"GET\""));
        assert!(line.contains("100"));
    }

    #[test]
    fn test_prometheus_metric_with_timestamp() {
        let metric = PrometheusMetric::new("test_metric", MetricType::Gauge, Labels::new(), 42.0)
            .with_timestamp(1699527600000);

        let line = metric.to_prometheus_line();
        assert!(line.contains("1699527600000"));
    }

    #[test]
    fn test_type_declaration() {
        let counter =
            PrometheusMetric::new("requests_total", MetricType::Counter, Labels::new(), 0.0);
        assert_eq!(counter.type_declaration(), "# TYPE requests_total counter");

        let gauge = PrometheusMetric::new("temperature", MetricType::Gauge, Labels::new(), 0.0);
        assert_eq!(gauge.type_declaration(), "# TYPE temperature gauge");
    }

    #[test]
    fn test_is_valid() {
        let valid = PrometheusMetric::new("test", MetricType::Gauge, Labels::new(), 42.0);
        assert!(valid.is_valid());

        let invalid_nan = PrometheusMetric::new("test", MetricType::Gauge, Labels::new(), f64::NAN);
        assert!(!invalid_nan.is_valid());

        let invalid_inf =
            PrometheusMetric::new("test", MetricType::Gauge, Labels::new(), f64::INFINITY);
        assert!(!invalid_inf.is_valid());
    }

    #[test]
    fn test_metric_ordering() {
        let metric1 = PrometheusMetric::new("aaa_metric", MetricType::Counter, Labels::new(), 1.0);
        let metric2 = PrometheusMetric::new("zzz_metric", MetricType::Counter, Labels::new(), 1.0);

        assert!(metric1 < metric2);
    }
}