Skip to main content

cli/
perf.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Developer-facing performance profiling helpers.
3//!
4//! The profile surface is intentionally env-gated so the public CLI
5//! stays focused. `HEDDLE_PROFILE=1` writes human-readable timings to
6//! stderr, while `HEDDLE_PROFILE=jsonl` writes one structured trace line to
7//! stderr. stdout remains reserved for normal text/JSON command output.
8
9use std::{cell::RefCell, collections::BTreeMap, time::Duration};
10
11use serde::Serialize;
12
13const PROFILE_SCHEMA: &str = "heddle-cli-profile/v1";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum ProfileMode {
17    Off,
18    Human,
19    Jsonl,
20}
21
22#[derive(Clone, Copy, Debug)]
23pub struct ProfileField {
24    pub name: &'static str,
25    pub value: u128,
26    unit: ProfileMetricUnit,
27}
28
29impl ProfileField {
30    pub fn millis(name: &'static str, value_ms: u128) -> Self {
31        Self {
32            name,
33            value: value_ms,
34            unit: ProfileMetricUnit::Milliseconds,
35        }
36    }
37
38    pub fn duration(name: &'static str, value: Duration) -> Self {
39        Self {
40            name,
41            value: value.as_millis(),
42            unit: ProfileMetricUnit::Milliseconds,
43        }
44    }
45
46    pub fn count(name: &'static str, value: impl Into<u128>) -> Self {
47        Self {
48            name,
49            value: value.into(),
50            unit: ProfileMetricUnit::Count,
51        }
52    }
53}
54
55#[derive(Clone, Copy, Debug, Serialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ProfileMetricUnit {
58    Milliseconds,
59    Count,
60}
61
62#[derive(Clone, Debug, Serialize)]
63pub struct ProfileMetricValue {
64    value: u128,
65    unit: ProfileMetricUnit,
66}
67
68#[derive(Clone, Debug, Serialize)]
69pub struct ProfilePhaseRecord {
70    name: String,
71    metrics: BTreeMap<String, ProfileMetricValue>,
72}
73
74#[derive(Clone, Debug, Serialize)]
75pub struct ProfileTraceRecord {
76    schema: &'static str,
77    command: String,
78    exit_status: &'static str,
79    phases: Vec<ProfilePhaseRecord>,
80    totals: BTreeMap<String, ProfileMetricValue>,
81}
82
83thread_local! {
84    static JSONL_PHASES: RefCell<Vec<ProfilePhaseRecord>> = const { RefCell::new(Vec::new()) };
85}
86
87pub fn profile_mode() -> ProfileMode {
88    std::env::var("HEDDLE_PROFILE")
89        .map(|value| {
90            let normalized = value.trim().to_ascii_lowercase();
91            match normalized.as_str() {
92                "" | "0" | "false" | "no" | "off" => ProfileMode::Off,
93                "jsonl" => ProfileMode::Jsonl,
94                _ => ProfileMode::Human,
95            }
96        })
97        .unwrap_or(ProfileMode::Off)
98}
99
100pub fn profile_enabled() -> bool {
101    profile_mode() != ProfileMode::Off
102}
103
104pub fn emit_profile(command: &str, fields: &[ProfileField]) {
105    match profile_mode() {
106        ProfileMode::Off => {}
107        ProfileMode::Human => emit_human_profile(command, fields),
108        ProfileMode::Jsonl => record_phase(command, fields),
109    }
110}
111
112pub fn emit_command_profile(command: &str, exit_status: i32, totals: &[ProfileField]) {
113    match profile_mode() {
114        ProfileMode::Off => {}
115        ProfileMode::Human => emit_human_profile(command, totals),
116        ProfileMode::Jsonl => emit_jsonl_trace(command, exit_status, totals),
117    }
118}
119
120fn emit_human_profile(command: &str, fields: &[ProfileField]) {
121    eprintln!("heddle profile:");
122    eprintln!("  command: {command}");
123    for field in fields {
124        eprintln!("  {}: {}", field.name, field.value);
125    }
126}
127
128fn record_phase(command: &str, fields: &[ProfileField]) {
129    let phase = ProfilePhaseRecord {
130        name: command.to_string(),
131        metrics: fields_to_metrics(fields),
132    };
133    JSONL_PHASES.with(|phases| phases.borrow_mut().push(phase));
134}
135
136fn emit_jsonl_trace(command: &str, exit_status: i32, totals: &[ProfileField]) {
137    let phases = JSONL_PHASES.with(|records| std::mem::take(&mut *records.borrow_mut()));
138    let trace = ProfileTraceRecord {
139        schema: PROFILE_SCHEMA,
140        command: command.to_string(),
141        exit_status: if exit_status == 0 { "ok" } else { "error" },
142        phases,
143        totals: fields_to_metrics(totals),
144    };
145    match serde_json::to_string(&trace) {
146        Ok(line) => eprintln!("{line}"),
147        Err(err) => eprintln!("heddle profile jsonl error: {err}"),
148    }
149}
150
151fn fields_to_metrics(fields: &[ProfileField]) -> BTreeMap<String, ProfileMetricValue> {
152    fields
153        .iter()
154        .map(|field| {
155            (
156                field.name.to_string(),
157                ProfileMetricValue {
158                    value: field.value,
159                    unit: field.unit,
160                },
161            )
162        })
163        .collect()
164}