Skip to main content

cortex/frontend/
helpers.rs

1//! General purpose auxiliary routines that do not fit the MVC web service paradigm,
2//! tending to minor tasks
3use crate::frontend::params::TemplateContext;
4
5/// The "generated at" timestamp shown in report footers: the server's local date and time **to the
6/// minute**, suffixed with the time-zone *abbreviation* — e.g. `Sat, 13 Jun 2026 22:57 EDT`.
7///
8/// chrono's `%Z` only renders the numeric UTC offset (`-04:00`) for `Local`, so the abbreviation
9/// comes from the C library's `strftime %Z`, which reads the OS time-zone database and is
10/// DST-correct. Falls back to chrono's offset rendering if the platform yields no abbreviation, so
11/// the zone is never blank.
12pub fn report_timestamp() -> String {
13  let now = chrono::Local::now();
14  let abbrev = local_tz_abbrev();
15  if abbrev.is_empty() {
16    now.format("%a, %d %b %Y %H:%M %Z").to_string()
17  } else {
18    format!("{} {}", now.format("%a, %d %b %Y %H:%M"), abbrev)
19  }
20}
21
22/// The local time-zone abbreviation (`EDT`, `EST`, `UTC`, …) from the C library's `strftime`, or an
23/// empty string if unavailable — the standard, DST-correct source chrono does not expose for
24/// `Local`.
25fn local_tz_abbrev() -> String {
26  // SAFETY: `localtime_r` (the reentrant variant) fully initializes the stack `tm`; `strftime`
27  // writes at most `buf.len()` bytes and returns the count written (0 on overflow). No borrowed
28  // pointers escape, and both calls are thread-safe.
29  unsafe {
30    let now: libc::time_t = libc::time(std::ptr::null_mut());
31    let mut tm: libc::tm = std::mem::zeroed();
32    if libc::localtime_r(&now, &mut tm).is_null() {
33      return String::new();
34    }
35    let mut buf = [0u8; 16];
36    let written = libc::strftime(
37      buf.as_mut_ptr() as *mut libc::c_char,
38      buf.len(),
39      c"%Z".as_ptr(),
40      &tm,
41    );
42    String::from_utf8_lossy(&buf[..written]).into_owned()
43  }
44}
45
46/// Formats a UTC [`chrono::NaiveDateTime`] (how CorTeX stores every timestamp) as an RFC 3339 /
47/// ISO 8601 string with an explicit `+00:00` offset, e.g. `2026-06-15T05:52:00+00:00`. This is the
48/// machine-readable, zone-unambiguous form emitted in DTO time fields and `<time datetime="…">`
49/// attributes: the browser ([`public/js/localtime.js`]) rewrites it to the viewer's local time
50/// *with the zone code* (EST/EDT/…), and agents get a directly parseable timestamp. Replaces the
51/// old zone-ambiguous `%Y-%m-%d %H:%M` rendering, which silently displayed UTC as if it were local.
52pub fn iso_utc(time: chrono::NaiveDateTime) -> String {
53  // Seconds precision (no sub-second microseconds) — cleaner in the datetime attribute and as the
54  // JS-off fallback text, still a valid RFC 3339 timestamp.
55  time
56    .and_utc()
57    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
58}
59
60/// Groups an integer into thousands with commas (`2820484` → `2,820,484`) for human-facing counts
61/// (corpus document totals, fleet throughput) that can reach millions. Agents get the raw number;
62/// only the rendered HTML is grouped.
63pub fn group_thousands(n: i64) -> String {
64  let digits = n.unsigned_abs().to_string();
65  let mut grouped = String::new();
66  for (i, ch) in digits.chars().enumerate() {
67    if i > 0 && (digits.len() - i).is_multiple_of(3) {
68      grouped.push(',');
69    }
70    grouped.push(ch);
71  }
72  if n < 0 {
73    format!("-{grouped}")
74  } else {
75    grouped
76  }
77}
78
79/// Maps a cortex message severity into a bootstrap class for color highlight
80pub fn severity_highlight(severity: &str) -> &str {
81  match severity {
82    // Bootstrap highlight classes
83    "no_problem" => "success",
84    "warning" => "warning",
85    "error" => "error",
86    "fatal" => "danger",
87    "invalid" => "info",
88    _ => "info",
89  }
90}
91/// TODO: Is this outdated?
92/// Maps a URI-encoded string into its regular plain text form
93pub fn uri_unescape(param: Option<&str>) -> Option<String> {
94  match param {
95    None => None,
96    Some(param_encoded) => {
97      let mut param_decoded: String = param_encoded.to_owned();
98      // TODO: This could/should be done faster by hoisting the table into a `LazyLock`.
99      for &(original, replacement) in &[
100        ("%3A", ":"),
101        ("%2F", "/"),
102        ("%24", "$"),
103        ("%2E", "."),
104        ("%21", "!"),
105        ("%40", "@"),
106      ] {
107        param_decoded = param_decoded.replace(original, replacement);
108      }
109      Some(
110        percent_encoding::percent_decode(param_decoded.as_bytes())
111          .decode_utf8_lossy()
112          .into_owned(),
113      )
114    },
115  }
116}
117/// TODO: Is this outdated?
118/// Maps a regular string into a URI-encoded one
119pub fn uri_escape(param: Option<String>) -> Option<String> {
120  match param {
121    None => None,
122    Some(param_pure) => {
123      let mut param_encoded: String =
124        percent_encoding::utf8_percent_encode(&param_pure, percent_encoding::NON_ALPHANUMERIC)
125          .collect::<String>();
126      // TODO: This could/should be done faster by hoisting the table into a `LazyLock`.
127      for &(original, replacement) in &[
128        (":", "%3A"),
129        ("/", "%2F"),
130        ("\\", "%5C"),
131        ("$", "%24"),
132        (".", "%2E"),
133        ("!", "%21"),
134        ("@", "%40"),
135      ] {
136        param_encoded = param_encoded.replace(original, replacement);
137      }
138      // if param_pure != param_encoded {
139      //   println!("Encoded {:?} to {:?}", param_pure, param_encoded);
140      // } else {
141      //   println!("No encoding needed: {:?}", param_pure);
142      // }
143      Some(param_encoded)
144    },
145  }
146}
147/// Auto-generates a URI-encoded "foo_uri" entry for each "foo" label associated with a clickable
148/// link (for Tera templates)
149pub fn decorate_uri_encodings(context: &mut TemplateContext) {
150  for inner_vec in &mut [
151    &mut context.corpora,
152    &mut context.services,
153    &mut context.entries,
154    &mut context.categories,
155    &mut context.whats,
156  ] {
157    if let Some(ref mut inner_vec_data) = **inner_vec {
158      for subhash in inner_vec_data {
159        let mut uri_decorations = vec![];
160        for (subkey, subval) in subhash.iter() {
161          uri_decorations.push((
162            subkey.to_string() + "_uri",
163            uri_escape(Some(subval.to_string())).unwrap_or_default(),
164          ));
165        }
166        for (decoration_key, decoration_val) in uri_decorations {
167          subhash.insert(decoration_key, decoration_val);
168        }
169      }
170    }
171  }
172  // global is handled separately
173  let mut uri_decorations = vec![];
174  for (subkey, subval) in &context.global {
175    uri_decorations.push((
176      subkey.to_string() + "_uri",
177      uri_escape(Some(subval.to_string())).unwrap_or_default(),
178    ));
179  }
180  for (decoration_key, decoration_val) in uri_decorations {
181    context.global.insert(decoration_key, decoration_val);
182  }
183  let mut current_link = String::new();
184  {
185    if let Some(corpus_name) = context.global.get("corpus_name_uri")
186      && let Some(service_name) = context.global.get("service_name_uri")
187    {
188      current_link = format!("/corpus/{corpus_name}/{service_name}/");
189      if let Some(severity) = context.global.get("severity_uri") {
190        current_link.push_str(severity);
191        current_link.push('/');
192        if let Some(category) = context.global.get("category_uri") {
193          current_link.push_str(category);
194          current_link.push('/');
195          if let Some(what) = context.global.get("what_uri") {
196            current_link.push_str(what);
197          }
198        }
199      }
200    }
201  }
202  if !current_link.is_empty() {
203    context
204      .global
205      .insert("current_link_uri".to_string(), current_link);
206  }
207}
208
209#[cfg(test)]
210mod tests {
211  use super::group_thousands;
212
213  #[test]
214  fn group_thousands_inserts_separators() {
215    assert_eq!(group_thousands(0), "0");
216    assert_eq!(group_thousands(7), "7");
217    assert_eq!(group_thousands(999), "999");
218    assert_eq!(group_thousands(1000), "1,000");
219    assert_eq!(group_thousands(12345), "12,345");
220    assert_eq!(group_thousands(2_820_484), "2,820,484");
221    assert_eq!(group_thousands(-1234), "-1,234");
222  }
223}