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

//! Label handling for metrics.
//!
//! Labels are key-value pairs that add dimensions to metrics. They are sorted
//! lexicographically and formatted according to Prometheus conventions.

use std::fmt;

/// Represents a collection of metric labels (key-value pairs).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Labels {
    /// Sorted label pairs for consistent output.
    pairs: Vec<(String, String)>,
}

impl Labels {
    /// Create a new empty label set.
    pub fn new() -> Self {
        Self { pairs: Vec::new() }
    }

    /// Add a label to the set.
    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.pairs.push((key.into(), value.into()));
        self.pairs.sort_by(|a, b| a.0.cmp(&b.0));
        self
    }

    /// Get the number of labels.
    pub fn len(&self) -> usize {
        self.pairs.len()
    }

    /// Check if there are any labels.
    pub fn is_empty(&self) -> bool {
        self.pairs.is_empty()
    }

    /// Check if a label key exists.
    pub fn contains_key(&self, key: &str) -> bool {
        self.pairs.iter().any(|(k, _)| k == key)
    }

    /// Get a label value by key.
    pub fn get(&self, key: &str) -> Option<&str> {
        self.pairs
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.as_str())
    }

    /// Iterate over label pairs.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
        self.pairs.iter().map(|(k, v)| (k.as_str(), v.as_str()))
    }
}

impl From<Vec<(String, String)>> for Labels {
    fn from(mut pairs: Vec<(String, String)>) -> Self {
        // Validate and sort labels
        pairs.sort_by(|a, b| a.0.cmp(&b.0));
        Self { pairs }
    }
}

impl From<Vec<(&str, &str)>> for Labels {
    fn from(pairs: Vec<(&str, &str)>) -> Self {
        let mut converted: Vec<(String, String)> = pairs
            .into_iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        converted.sort_by(|a, b| a.0.cmp(&b.0));
        Self { pairs: converted }
    }
}

impl fmt::Display for Labels {
    /// Format labels in Prometheus convention: `{key="value",key2="value2"}`
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.pairs.is_empty() {
            return Ok(());
        }

        write!(f, "{{")?;

        for (i, (key, value)) in self.pairs.iter().enumerate() {
            if i > 0 {
                write!(f, ",")?;
            }
            // Escape quotes in values
            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
            write!(f, "{}=\"{}\"", key, escaped)?;
        }

        write!(f, "}}")
    }
}

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

    #[test]
    fn test_labels_from_vec_of_strings() {
        let labels = Labels::from(vec![
            ("method".to_string(), "GET".to_string()),
            ("status".to_string(), "200".to_string()),
        ]);

        assert_eq!(labels.len(), 2);
        assert_eq!(labels.get("method"), Some("GET"));
        assert_eq!(labels.get("status"), Some("200"));
    }

    #[test]
    fn test_labels_from_vec_of_refs() {
        let labels = Labels::from(vec![("method", "GET"), ("status", "200")]);

        assert_eq!(labels.len(), 2);
        assert_eq!(labels.get("method"), Some("GET"));
    }

    #[test]
    fn test_labels_sorting() {
        let labels = Labels::from(vec![
            ("z".to_string(), "last".to_string()),
            ("a".to_string(), "first".to_string()),
            ("m".to_string(), "middle".to_string()),
        ]);

        let formatted = labels.to_string();
        let a_pos = formatted.find("a=").unwrap();
        let m_pos = formatted.find("m=").unwrap();
        let z_pos = formatted.find("z=").unwrap();

        assert!(a_pos < m_pos && m_pos < z_pos, "Labels should be sorted");
    }

    #[test]
    fn test_labels_empty() {
        let labels = Labels::new();
        assert!(labels.is_empty());
        assert_eq!(labels.to_string(), "");
    }

    #[test]
    fn test_labels_with_quotes() {
        let labels = Labels::from(vec![("key".to_string(), "value\"quoted\"".to_string())]);

        let formatted = labels.to_string();
        assert!(formatted.contains("\\\""));
    }

    #[test]
    fn test_labels_with_backslash() {
        let labels = Labels::from(vec![("path".to_string(), "C:\\Users\\test".to_string())]);

        let formatted = labels.to_string();
        assert!(formatted.contains("\\\\"));
    }

    #[test]
    fn test_labels_contains_key() {
        let labels = Labels::from(vec![("method".to_string(), "GET".to_string())]);

        assert!(labels.contains_key("method"));
        assert!(!labels.contains_key("status"));
    }

    #[test]
    fn test_labels_iterator() {
        let labels = Labels::from(vec![
            ("a".to_string(), "1".to_string()),
            ("b".to_string(), "2".to_string()),
        ]);

        let pairs: Vec<_> = labels.iter().collect();
        assert_eq!(pairs, vec![("a", "1"), ("b", "2")]);
    }
}