Skip to main content

hotpath/
output.rs

1use serde::ser::Serializer;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::fs::File;
5use std::io::Write;
6use std::path::PathBuf;
7use std::sync::LazyLock;
8
9const DEFAULT_MAX_LOG_LEN: usize = 1536;
10pub static MAX_LOG_LEN: LazyLock<usize> = LazyLock::new(|| {
11    std::env::var("HOTPATH_MAX_LOG_LEN")
12        .ok()
13        .and_then(|s| s.parse().ok())
14        .unwrap_or(DEFAULT_MAX_LOG_LEN)
15});
16
17const DEFAULT_FUNCTIONS_NAME_DEPTH: usize = 2;
18pub static FUNCTIONS_NAME_DEPTH: LazyLock<usize> = LazyLock::new(|| {
19    std::env::var("HOTPATH_FUNCTIONS_NAME_DEPTH")
20        .ok()
21        .and_then(|s| s.parse().ok())
22        .unwrap_or(DEFAULT_FUNCTIONS_NAME_DEPTH)
23});
24
25/// Destination for profiling report output.
26#[derive(Default)]
27pub enum OutputDestination {
28    #[default]
29    Stdout,
30    File(PathBuf),
31}
32
33/// Formats a duration in nanoseconds into a human-readable string with appropriate units.
34pub fn format_duration(ns: u64) -> String {
35    if ns < 1_000 {
36        format!("{} ns", ns)
37    } else if ns < 1_000_000 {
38        format!("{:.2} µs", ns as f64 / 1_000.0)
39    } else if ns < 1_000_000_000 {
40        format!("{:.2} ms", ns as f64 / 1_000_000.0)
41    } else {
42        format!("{:.2} s", ns as f64 / 1_000_000_000.0)
43    }
44}
45
46/// Parses a human-readable duration string back to nanoseconds.
47/// Inverse of [`format_duration`].
48pub fn parse_duration(s: &str) -> Option<u64> {
49    let s = s.trim();
50    if let Some(num) = s.strip_suffix(" ns") {
51        num.trim().parse::<f64>().ok().map(|v| v.round() as u64)
52    } else if let Some(num) = s.strip_suffix(" µs") {
53        num.trim()
54            .parse::<f64>()
55            .ok()
56            .map(|v| (v * 1_000.0).round() as u64)
57    } else if let Some(num) = s.strip_suffix(" ms") {
58        num.trim()
59            .parse::<f64>()
60            .ok()
61            .map(|v| (v * 1_000_000.0).round() as u64)
62    } else if let Some(num) = s.strip_suffix(" s") {
63        num.trim()
64            .parse::<f64>()
65            .ok()
66            .map(|v| (v * 1_000_000_000.0).round() as u64)
67    } else {
68        None
69    }
70}
71
72/// Formats a percentile value for use as a map key (e.g., `"p95"`, `"p99.9"`).
73pub fn format_percentile_key(p: f64) -> String {
74    if p.fract() == 0.0 {
75        format!("p{}", p as u64)
76    } else {
77        format!("p{}", p)
78    }
79}
80
81/// Formats a percentile value for display as a column header (e.g., `"P95"`, `"P99.9"`).
82pub fn format_percentile_header(p: f64) -> String {
83    if p.fract() == 0.0 {
84        format!("P{}", p as u64)
85    } else {
86        format!("P{}", p)
87    }
88}
89
90/// Formats a byte count into a human-readable string (e.g., "1.5 MB").
91pub fn format_bytes(bytes: u64) -> String {
92    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
93    const THRESHOLD: f64 = 1024.0;
94
95    if bytes == 0 {
96        return "0 B".to_string();
97    }
98
99    let bytes_f = bytes as f64;
100    let unit_index = (bytes_f.log(THRESHOLD).floor() as usize).min(UNITS.len() - 1);
101    let unit_value = bytes_f / THRESHOLD.powi(unit_index as i32);
102
103    if unit_index == 0 {
104        format!("{} {}", bytes, UNITS[unit_index])
105    } else {
106        format!("{:.1} {}", unit_value, UNITS[unit_index])
107    }
108}
109
110/// Formats an optional per-second rate to one decimal place, or `-` when absent.
111pub fn format_rate(rate: Option<f64>) -> String {
112    rate.map_or_else(|| "-".to_string(), |v| format!("{v:.1}"))
113}
114
115/// Parses a human-readable byte string back to a byte count.
116/// Inverse of [`format_bytes`].
117pub fn parse_bytes(s: &str) -> Option<u64> {
118    let s = s.trim();
119    if let Some(num) = s.strip_suffix(" TB") {
120        num.trim()
121            .parse::<f64>()
122            .ok()
123            .map(|v| (v * 1024.0_f64.powi(4)).round() as u64)
124    } else if let Some(num) = s.strip_suffix(" GB") {
125        num.trim()
126            .parse::<f64>()
127            .ok()
128            .map(|v| (v * 1024.0_f64.powi(3)).round() as u64)
129    } else if let Some(num) = s.strip_suffix(" MB") {
130        num.trim()
131            .parse::<f64>()
132            .ok()
133            .map(|v| (v * 1024.0_f64.powi(2)).round() as u64)
134    } else if let Some(num) = s.strip_suffix(" KB") {
135        num.trim()
136            .parse::<f64>()
137            .ok()
138            .map(|v| (v * 1024.0).round() as u64)
139    } else if let Some(num) = s.strip_suffix(" B") {
140        num.trim().parse::<u64>().ok()
141    } else {
142        None
143    }
144}
145
146/// Formats an allocation count as a string.
147pub fn format_count(count: u64) -> String {
148    count.to_string()
149}
150
151/// Parses a count string back to a u64.
152/// Inverse of [`format_count`].
153pub fn parse_count(s: &str) -> Option<u64> {
154    s.trim().parse::<u64>().ok()
155}
156
157/// Profiling mode indicating what type of measurements were collected.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum ProfilingMode {
160    /// Time-based profiling (execution duration)
161    Timing,
162    /// Allocation profiling with bytes as primary metric
163    AllocBytes,
164    /// Allocation profiling with count as primary metric
165    AllocCount,
166}
167
168impl fmt::Display for ProfilingMode {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            ProfilingMode::Timing => write!(f, "timing"),
172            ProfilingMode::AllocBytes => write!(f, "alloc-bytes"),
173            ProfilingMode::AllocCount => write!(f, "alloc-count"),
174        }
175    }
176}
177
178#[cfg(feature = "hotpath")]
179static USE_COLORS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
180
181#[cfg(feature = "hotpath")]
182pub(crate) fn set_use_colors(value: bool) {
183    let _ = USE_COLORS.set(value);
184}
185
186#[cfg(feature = "hotpath")]
187pub(crate) fn use_colors() -> bool {
188    *USE_COLORS.get().unwrap_or(&false)
189}
190
191#[cfg(feature = "hotpath-cpu")]
192pub(crate) fn cyan(text: &str) -> String {
193    if use_colors() {
194        format!("\x1b[1;36m{text}\x1b[0m")
195    } else {
196        text.to_string()
197    }
198}
199
200impl OutputDestination {
201    /// Creates a writer for this destination.
202    ///
203    /// Returns a boxed writer that implements `Write`.
204    /// For `Stdout`, returns a handle to stdout.
205    /// For `File`, creates parent directories if needed, then creates or truncates the file.
206    pub fn writer(&self) -> Result<Box<dyn Write>, std::io::Error> {
207        match self {
208            OutputDestination::Stdout => Ok(Box::new(std::io::stdout())),
209            OutputDestination::File(path) => {
210                if let Some(parent) = path.parent() {
211                    std::fs::create_dir_all(parent)?;
212                }
213                Ok(Box::new(File::create(path)?))
214            }
215        }
216    }
217
218    /// Creates an OutputDestination from an optional path.
219    ///
220    /// Environment variable `HOTPATH_OUTPUT_PATH` takes precedence over programmatic config.
221    /// If the path is provided, resolves relative paths against the current working directory.
222    /// If no path is provided, returns Stdout.
223    pub fn from_path(path: Option<PathBuf>) -> Self {
224        if let Ok(env_path) = std::env::var("HOTPATH_OUTPUT_PATH") {
225            return OutputDestination::File(resolve_output_path(env_path));
226        }
227
228        match path {
229            Some(p) => OutputDestination::File(p),
230            None => OutputDestination::Stdout,
231        }
232    }
233}
234
235/// Resolves a path, converting relative paths to absolute by joining with cwd.
236pub(crate) fn resolve_output_path(path: impl AsRef<std::path::Path>) -> PathBuf {
237    let path = path.as_ref();
238    if path.is_absolute() {
239        path.to_path_buf()
240    } else {
241        std::env::current_dir()
242            .map(|cwd| cwd.join(path))
243            .unwrap_or_else(|_| path.to_path_buf())
244    }
245}
246
247impl Serialize for ProfilingMode {
248    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
249    where
250        S: Serializer,
251    {
252        match self {
253            ProfilingMode::Timing => serializer.serialize_str("timing"),
254            ProfilingMode::AllocBytes => serializer.serialize_str("alloc-bytes"),
255            ProfilingMode::AllocCount => serializer.serialize_str("alloc-count"),
256        }
257    }
258}
259
260impl<'de> Deserialize<'de> for ProfilingMode {
261    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262    where
263        D: serde::Deserializer<'de>,
264    {
265        let s = String::deserialize(deserializer)?;
266        match s.as_str() {
267            "timing" => Ok(ProfilingMode::Timing),
268            "alloc-bytes" => Ok(ProfilingMode::AllocBytes),
269            "alloc-count" => Ok(ProfilingMode::AllocCount),
270            _ => Err(serde::de::Error::unknown_variant(
271                &s,
272                &["timing", "alloc-bytes", "alloc-count"],
273            )),
274        }
275    }
276}
277
278pub fn floor_char_boundary(s: &str, index: usize) -> usize {
279    if index >= s.len() {
280        return s.len();
281    }
282    let mut i = index;
283    while i > 0 && !s.is_char_boundary(i) {
284        i -= 1;
285    }
286    i
287}
288
289pub fn ceil_char_boundary(s: &str, index: usize) -> usize {
290    if index >= s.len() {
291        return s.len();
292    }
293    let mut i = index;
294    while i < s.len() && !s.is_char_boundary(i) {
295        i += 1;
296    }
297    i
298}
299
300#[cfg(feature = "hotpath")]
301struct TruncatingWriter {
302    buf: String,
303    limit: usize,
304    truncated: bool,
305}
306
307#[cfg(feature = "hotpath")]
308impl std::fmt::Write for TruncatingWriter {
309    fn write_str(&mut self, s: &str) -> std::fmt::Result {
310        if self.truncated {
311            return Ok(());
312        }
313
314        let remaining = self.limit.saturating_sub(self.buf.len());
315        if remaining == 0 {
316            if !s.is_empty() {
317                self.truncated = true;
318            }
319            return Ok(());
320        }
321
322        let end = floor_char_boundary(s, s.len().min(remaining));
323
324        if end < s.len() {
325            self.truncated = true;
326        }
327
328        self.buf.push_str(&s[..end]);
329        Ok(())
330    }
331}
332
333#[cfg(feature = "hotpath")]
334#[cfg_attr(feature = "hotpath-meta", hotpath_meta::measure)]
335pub fn format_debug_truncated(value: &impl std::fmt::Debug) -> String {
336    let _suspend = crate::lib_on::SuspendAllocTracking::new();
337    use std::fmt::Write;
338    let limit = MAX_LOG_LEN.saturating_sub(3);
339    let mut writer = TruncatingWriter {
340        buf: String::with_capacity(64),
341        limit,
342        truncated: false,
343    };
344    let _ = write!(writer, "{:?}", value);
345
346    if writer.truncated {
347        writer.buf.push_str("...");
348    }
349
350    writer.buf
351}
352
353pub fn shorten_function_name(function_name: &str) -> String {
354    let depth = *FUNCTIONS_NAME_DEPTH;
355    if depth == 0 {
356        return function_name.to_string();
357    }
358    let parts: Vec<&str> = function_name.split("::").collect();
359    if parts.len() > depth {
360        parts[parts.len() - depth..].join("::")
361    } else {
362        function_name.to_string()
363    }
364}
365
366/// A single log entry for a function invocation.
367///
368/// - For timing mode: `value` is duration in nanoseconds, `alloc_count` is None
369/// - For alloc mode with valid data: `value` is bytes allocated, `alloc_count` is allocation count
370/// - For alloc mode with invalid data: `value` and `alloc_count` are None (cross-thread or unsupported async)
371/// - `tid` is None if cross-thread execution was detected
372/// - `result` contains the Debug representation of the return value when `log = true`
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[allow(dead_code)]
375pub(crate) struct FunctionLog {
376    /// Measured value (duration in ns for timing, bytes for memory). None if invalid.
377    pub value: Option<u64>,
378    /// Timestamp when the measurement was taken (nanoseconds since profiler start)
379    pub elapsed_nanos: u64,
380    /// Allocation count (only for memory mode)
381    pub alloc_count: Option<u64>,
382    /// Thread ID where the function was executed, None if cross-thread execution
383    pub tid: Option<u64>,
384    /// Debug representation of the return value (when log = true)
385    pub result: Option<String>,
386}
387
388/// Response containing recent logs for a function
389#[derive(Debug, Clone)]
390#[allow(dead_code)]
391pub(crate) struct FunctionLogsList {
392    pub function_name: String,
393    pub logs: Vec<FunctionLog>,
394    /// Total number of times this function was invoked (used to calculate invocation numbers)
395    pub count: usize,
396}
397
398#[cfg(test)]
399mod truncation_tests {
400    use super::*;
401
402    #[test]
403    fn test_format_debug_truncated() {
404        let truncate_point = MAX_LOG_LEN.saturating_sub(3);
405
406        let test_cases: Vec<(&str, String)> = vec![
407            (
408                "japanese at boundary",
409                format!("{}リプライ", "a".repeat(truncate_point - 2)),
410            ),
411            ("emoji", "🦀".repeat(500)),
412            ("chinese", "拥抱中文字符测试".repeat(200)),
413            (
414                "2-byte at boundary",
415                format!("{}ñoño", "a".repeat(truncate_point - 1)),
416            ),
417        ];
418
419        for (name, input) in test_cases {
420            let result = format_debug_truncated(&input);
421            assert!(
422                result.chars().count() > 0,
423                "{}: result should have chars",
424                name
425            );
426            if input.len() > *MAX_LOG_LEN {
427                assert!(
428                    result.ends_with("..."),
429                    "{}: truncated result should end with '...'",
430                    name
431                );
432            }
433        }
434    }
435}
436
437#[cfg(test)]
438mod parse_tests {
439    use super::*;
440
441    #[test]
442    fn test_parse_duration_units() {
443        assert_eq!(parse_duration("123 ns"), Some(123));
444        assert_eq!(parse_duration("0 ns"), Some(0));
445        assert_eq!(parse_duration("1.23 µs"), Some(1230));
446        assert_eq!(parse_duration("1.23 ms"), Some(1230000));
447        assert_eq!(parse_duration("1.23 s"), Some(1230000000));
448    }
449
450    #[test]
451    fn test_parse_duration_invalid() {
452        assert_eq!(parse_duration(""), None);
453        assert_eq!(parse_duration("invalid"), None);
454        assert_eq!(parse_duration("abc ns"), None);
455    }
456
457    #[test]
458    fn test_parse_duration_roundtrip() {
459        for val in [0, 1, 500, 999, 1000, 50_000, 1_230_000, 1_230_000_000] {
460            let formatted = format_duration(val);
461            let parsed = parse_duration(&formatted);
462            assert_eq!(
463                parsed,
464                Some(val),
465                "round-trip failed for {val}: formatted as '{formatted}'"
466            );
467        }
468    }
469
470    #[test]
471    fn test_parse_bytes_units() {
472        assert_eq!(parse_bytes("0 B"), Some(0));
473        assert_eq!(parse_bytes("123 B"), Some(123));
474        assert_eq!(parse_bytes("1.5 KB"), Some(1536));
475        assert_eq!(parse_bytes("1.0 MB"), Some(1048576));
476        assert_eq!(parse_bytes("1.0 GB"), Some(1073741824));
477        assert_eq!(parse_bytes("0.5 TB"), Some(549755813888));
478    }
479
480    #[test]
481    fn test_parse_bytes_invalid() {
482        assert_eq!(parse_bytes(""), None);
483        assert_eq!(parse_bytes("invalid"), None);
484        assert_eq!(parse_bytes("abc KB"), None);
485    }
486
487    #[test]
488    fn test_parse_bytes_roundtrip() {
489        for val in [0, 100, 1023, 1024, 1536, 1048576, 1073741824] {
490            let formatted = format_bytes(val);
491            let parsed = parse_bytes(&formatted);
492            assert_eq!(
493                parsed,
494                Some(val),
495                "round-trip failed for {val}: formatted as '{formatted}'"
496            );
497        }
498    }
499
500    #[test]
501    fn test_format_count() {
502        assert_eq!(format_count(0), "0");
503        assert_eq!(format_count(999), "999");
504        assert_eq!(format_count(1_000), "1000");
505        assert_eq!(format_count(1_000_000), "1000000");
506    }
507
508    #[test]
509    fn test_parse_count_roundtrip() {
510        for val in [0, 1, 500, 999, 1_000, 1_500, 50_000, 1_000_000] {
511            let formatted = format_count(val);
512            let parsed = parse_count(&formatted);
513            assert_eq!(
514                parsed,
515                Some(val),
516                "round-trip failed for {val}: formatted as '{formatted}'"
517            );
518        }
519    }
520}