1use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct Sample {
8 pub name: String,
9 pub labels: Vec<(String, String)>,
10 pub value: f64,
11}
12
13impl Sample {
14 pub fn label(&self, name: &str) -> Option<&str> {
15 self.labels
16 .iter()
17 .find(|(k, _)| k == name)
18 .map(|(_, v)| v.as_str())
19 }
20
21 pub fn series(&self) -> String {
23 if self.labels.is_empty() {
24 return self.name.clone();
25 }
26 let labels: Vec<String> = self
27 .labels
28 .iter()
29 .map(|(k, v)| format!("{k}=\"{v}\""))
30 .collect();
31 format!("{}{{{}}}", self.name, labels.join(","))
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Default)]
36pub struct Metrics {
37 pub samples: Vec<Sample>,
38 types: HashMap<String, String>,
39}
40
41impl Metrics {
42 pub fn parse(text: &str) -> Metrics {
43 let mut metrics = Metrics::default();
44 for line in text.lines() {
45 let line = line.trim();
46 if let Some(rest) = line.strip_prefix("# TYPE ") {
47 if let Some((name, kind)) = rest.split_once(' ') {
48 metrics
49 .types
50 .insert(name.to_owned(), kind.trim().to_owned());
51 }
52 } else if !line.is_empty() && !line.starts_with('#') {
53 if let Some(sample) = parse_sample(line) {
54 metrics.samples.push(sample);
55 }
56 }
57 }
58 metrics
59 }
60
61 pub fn kind(&self, name: &str) -> Option<&str> {
63 self.types.get(name).map(String::as_str)
64 }
65
66 pub fn is_counter(&self, name: &str) -> bool {
68 self.kind(name) == Some("counter")
69 || name
70 .strip_suffix("_total")
71 .is_some_and(|base| self.kind(base) == Some("counter"))
72 }
73
74 pub fn sum(&self, name: &str) -> Option<f64> {
76 let mut found = false;
77 let mut total = 0.0;
78 for sample in self.samples.iter().filter(|s| s.name == name) {
79 found = true;
80 total += sample.value;
81 }
82 found.then_some(total)
83 }
84}
85
86pub fn rate(previous: f64, now: f64, seconds: f64) -> Option<f64> {
89 (seconds > 0.0 && now >= previous).then(|| (now - previous) / seconds)
90}
91
92fn parse_sample(line: &str) -> Option<Sample> {
93 let (head, rest) = match line.find('{') {
94 Some(open) => {
95 let close = closing_brace(line, open)?;
96 (&line[..=close], &line[close + 1..])
97 }
98 None => line.split_once(' ')?,
99 };
100 let (name, labels) = match head.find('{') {
101 Some(open) => (&head[..open], parse_labels(&head[open + 1..head.len() - 1])),
102 None => (head, Vec::new()),
103 };
104 let value = rest.split_whitespace().next()?;
105 Some(Sample {
106 name: name.to_owned(),
107 labels,
108 value: parse_value(value)?,
109 })
110}
111
112fn parse_value(text: &str) -> Option<f64> {
113 match text {
114 "+Inf" | "Inf" => Some(f64::INFINITY),
115 "-Inf" => Some(f64::NEG_INFINITY),
116 "NaN" => Some(f64::NAN),
117 other => other.parse().ok(),
118 }
119}
120
121fn closing_brace(line: &str, open: usize) -> Option<usize> {
123 let mut quoted = false;
124 let mut escaped = false;
125 for (i, c) in line.char_indices().skip_while(|(i, _)| *i < open) {
126 match c {
127 _ if escaped => escaped = false,
128 '\\' if quoted => escaped = true,
129 '"' => quoted = !quoted,
130 '}' if !quoted => return Some(i),
131 _ => {}
132 }
133 }
134 None
135}
136
137fn parse_labels(text: &str) -> Vec<(String, String)> {
138 let mut labels = Vec::new();
139 let mut rest = text;
140 while let Some((name, after)) = rest.split_once("=\"") {
141 let mut value = String::new();
142 let mut chars = after.char_indices();
143 let mut end = after.len();
144 while let Some((i, c)) = chars.next() {
145 match c {
146 '\\' => match chars.next() {
147 Some((_, 'n')) => value.push('\n'),
148 Some((_, other)) => value.push(other),
149 None => break,
150 },
151 '"' => {
152 end = i + 1;
153 break;
154 }
155 other => value.push(other),
156 }
157 }
158 labels.push((name.trim_start_matches(',').trim().to_owned(), value));
159 rest = after.get(end..).unwrap_or("");
160 }
161 labels
162}
163
164#[cfg(test)]
165mod tests;