Skip to main content

azul_core/
profile.rs

1//! Unified profiling gate.
2//!
3//! Reads `AZ_PROFILE` once on first access, caches the result forever.
4//! Value is a comma-separated list of tokens; unknown tokens are ignored,
5//! whitespace is trimmed, matching is case-insensitive.
6//!
7//! Tokens:
8//! - `memory`  — heap-breakdown dumps (StyledDom, LayoutCache, text cache,
9//!               cascade maps, RSS). Printed to stderr once per frame.
10//! - `cpu`     — per-phase wall-clock timings from `Probe::span` (layout,
11//!               style, cascade, paint, callbacks, …), dumped once per
12//!               frame so stuttering frames are easy to spot.
13//! - `cascade` — narrow diagnostic for prop-cache work: top-N CSS
14//!               properties by cascade-walk count per frame.
15//! - `heap`    — phase-boundary heap probes in `regenerate_layout`
16//!               (`emit_phase_heap`). By themselves print nothing —
17//!               pair with `jsonl` + `AZ_PROFILE_OUT` to persist.
18//! - `jsonl`   — format heap probes as JSONL to the file named by
19//!               `AZ_PROFILE_OUT=<path>`. Requires `heap` to do anything.
20//! - `detail`  — opt-in to the fine-grained per-step probes inside each
21//!               phase (e.g. `rf_*` labels inside
22//!               `rust_fontconfig::request_fonts`, and the `_extra`
23//!               cache-size payloads). Layered on top of `heap`.
24//!
25//! ## Examples
26//! - `AZ_PROFILE=cpu` — per-phase CPU timings to stderr.
27//! - `AZ_PROFILE=heap,jsonl AZ_PROFILE_OUT=/tmp/run.jsonl`
28//!     → coarse phase heap probes to JSONL.
29//! - `AZ_PROFILE=heap,jsonl,detail AZ_PROFILE_OUT=/tmp/detail.jsonl`
30//!     → fine-grained (per-step) heap probes to JSONL.
31//! - `AZ_PROFILE=cpu,cascade` — both dumps simultaneously.
32//!
33//! Tokens are independent flags, not mutually exclusive modes. Unset
34//! or empty leaves every quick path silent.
35//!
36//! ## Path for jsonl output
37//! `AZ_PROFILE_OUT` is read separately (not folded into `AZ_PROFILE`
38//! because the value can contain `,` and `=` and a path is a different
39//! shape from a flag). When `jsonl` is set but `AZ_PROFILE_OUT` is
40//! unset, writers silently skip — no stderr fallback so benchmarks
41//! don't get polluted.
42//!
43//! ## Portability
44//! - **macOS / Linux**: full support. Span timings via `Instant`; RSS
45//!   checkpoints via `task_info` / `/proc/self/statm`.
46//! - **Windows**: span timings work. RSS checkpoints silently read 0
47//!   (the RSS helpers in `azul_layout::probe` are `cfg(unix)`-gated).
48//! - **WASM (`target_family = "wasm"`)**: `Instant::now()` panics on
49//!   browser WASM (no monotonic clock) and `libc::getrusage` isn't
50//!   available. The probe module detects WASM at compile time and
51//!   forces the no-op impl.
52
53#[cfg(feature = "std")]
54use std::sync::OnceLock;
55
56/// Set of active `AZ_PROFILE` tokens. Parsed once from the env var.
57// independent profile toggles parsed from the env var; a bitflags type would
58// not improve this flat set of named booleans.
59#[allow(clippy::struct_excessive_bools)]
60#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
61pub struct ProfileFlags {
62    pub memory: bool,
63    pub cpu: bool,
64    pub cascade: bool,
65    pub heap: bool,
66    pub jsonl: bool,
67    pub detail: bool,
68}
69
70impl ProfileFlags {
71    fn parse(value: &str) -> Self {
72        let mut f = Self::default();
73        for tok in value.split(',') {
74            let t = tok.trim();
75            if t.eq_ignore_ascii_case("memory") || t.eq_ignore_ascii_case("mem") {
76                f.memory = true;
77            } else if t.eq_ignore_ascii_case("cpu") || t.eq_ignore_ascii_case("perf") {
78                f.cpu = true;
79            } else if t.eq_ignore_ascii_case("cascade") || t.eq_ignore_ascii_case("css") {
80                f.cascade = true;
81            } else if t.eq_ignore_ascii_case("heap") {
82                f.heap = true;
83            } else if t.eq_ignore_ascii_case("jsonl") {
84                f.jsonl = true;
85            } else if t.eq_ignore_ascii_case("detail") {
86                f.detail = true;
87            }
88        }
89        f
90    }
91}
92
93#[cfg(feature = "std")]
94#[inline]
95pub fn flags() -> ProfileFlags {
96    static FLAGS: OnceLock<ProfileFlags> = OnceLock::new();
97    *FLAGS.get_or_init(|| {
98        std::env::var("AZ_PROFILE")
99            .map(|v| ProfileFlags::parse(&v))
100            .unwrap_or_default()
101    })
102}
103
104/// `no_std` builds have no environment; profiling is always off.
105#[cfg(not(feature = "std"))]
106#[inline]
107pub fn flags() -> ProfileFlags {
108    let _ = ProfileFlags::parse;
109    ProfileFlags::default()
110}
111
112/// `AZ_PROFILE_OUT=<path>` — destination for JSONL heap probes.
113/// Returns `None` if unset. Cached on first access.
114#[cfg(feature = "std")]
115#[inline]
116pub fn out_path() -> Option<&'static str> {
117    static PATH: OnceLock<Option<String>> = OnceLock::new();
118    PATH.get_or_init(|| std::env::var("AZ_PROFILE_OUT").ok())
119        .as_deref()
120}
121
122/// `no_std` builds have no environment; no output path.
123#[cfg(not(feature = "std"))]
124#[inline]
125pub fn out_path() -> Option<&'static str> {
126    None
127}
128
129#[inline]
130#[must_use] pub fn memory_enabled() -> bool { flags().memory }
131
132#[inline]
133#[must_use] pub fn cpu_enabled() -> bool { flags().cpu }
134
135#[inline]
136#[must_use] pub fn cascade_enabled() -> bool { flags().cascade }
137
138#[inline]
139#[must_use] pub fn heap_enabled() -> bool { flags().heap }
140
141#[inline]
142#[must_use] pub fn jsonl_enabled() -> bool { flags().jsonl }
143
144#[inline]
145#[must_use] pub fn detail_enabled() -> bool { flags().detail }
146
147#[cfg(test)]
148mod tests {
149    use super::ProfileFlags;
150
151    #[test]
152    fn parse_single_token() {
153        let f = ProfileFlags::parse("cpu");
154        assert!(f.cpu && !f.memory && !f.heap);
155    }
156
157    #[test]
158    fn parse_multiple_tokens() {
159        let f = ProfileFlags::parse("heap,jsonl,detail");
160        assert!(f.heap && f.jsonl && f.detail);
161        assert!(!f.cpu && !f.memory);
162    }
163
164    #[test]
165    fn parse_is_case_insensitive_and_trims() {
166        let f = ProfileFlags::parse(" Heap , JSONL ");
167        assert!(f.heap && f.jsonl);
168    }
169
170    #[test]
171    fn parse_ignores_unknown_tokens() {
172        let f = ProfileFlags::parse("cpu,bogus,heap");
173        assert!(f.cpu && f.heap);
174    }
175
176    #[test]
177    fn parse_accepts_aliases() {
178        let f = ProfileFlags::parse("mem,perf,css");
179        assert!(f.memory && f.cpu && f.cascade);
180    }
181}
182
183#[cfg(test)]
184#[allow(clippy::bool_assert_comparison)]
185mod autotest_generated {
186    use alloc::{
187        string::{String, ToString},
188        vec::Vec,
189    };
190
191    use super::*;
192
193    // ---- helpers ---------------------------------------------------------
194
195    /// Canonical token for every flag, in field-declaration order.
196    const CANONICAL: [&str; 6] = ["memory", "cpu", "cascade", "heap", "jsonl", "detail"];
197
198    /// The documented aliases, paired with the canonical token they mean.
199    const ALIASES: [(&str, &str); 3] = [("mem", "memory"), ("perf", "cpu"), ("css", "cascade")];
200
201    /// Read field `idx` of a flag set, indices matching `CANONICAL`.
202    fn field(f: &ProfileFlags, idx: usize) -> bool {
203        match idx {
204            0 => f.memory,
205            1 => f.cpu,
206            2 => f.cascade,
207            3 => f.heap,
208            4 => f.jsonl,
209            5 => f.detail,
210            _ => unreachable!("CANONICAL has 6 entries"),
211        }
212    }
213
214    /// Inverse of `ProfileFlags::parse`: render a flag set as an `AZ_PROFILE`
215    /// value. Used for the encode/decode round-trip below.
216    fn encode(f: ProfileFlags) -> String {
217        let mut parts: Vec<&str> = Vec::new();
218        if f.memory {
219            parts.push("memory");
220        }
221        if f.cpu {
222            parts.push("cpu");
223        }
224        if f.cascade {
225            parts.push("cascade");
226        }
227        if f.heap {
228            parts.push("heap");
229        }
230        if f.jsonl {
231            parts.push("jsonl");
232        }
233        if f.detail {
234            parts.push("detail");
235        }
236        parts.join(",")
237    }
238
239    /// Build a flag set from a 6-bit mask (bit i == field i in `CANONICAL`).
240    fn from_mask(mask: u8) -> ProfileFlags {
241        ProfileFlags {
242            memory: mask & 0b00_0001 != 0,
243            cpu: mask & 0b00_0010 != 0,
244            cascade: mask & 0b00_0100 != 0,
245            heap: mask & 0b00_1000 != 0,
246            jsonl: mask & 0b01_0000 != 0,
247            detail: mask & 0b10_0000 != 0,
248        }
249    }
250
251    fn any_set(f: &ProfileFlags) -> bool {
252        f.memory || f.cpu || f.cascade || f.heap || f.jsonl || f.detail
253    }
254
255    /// Core soundness invariant: a flag can only be set if the token that
256    /// enables it literally occurs in the input (ASCII-case-insensitively).
257    /// `parse` never invents flags out of thin air.
258    fn assert_flags_are_justified(input: &str, f: &ProfileFlags) {
259        let lower = input.to_ascii_lowercase();
260        if f.memory {
261            assert!(lower.contains("memory") || lower.contains("mem"));
262        }
263        if f.cpu {
264            assert!(lower.contains("cpu") || lower.contains("perf"));
265        }
266        if f.cascade {
267            assert!(lower.contains("cascade") || lower.contains("css"));
268        }
269        if f.heap {
270            assert!(lower.contains("heap"));
271        }
272        if f.jsonl {
273            assert!(lower.contains("jsonl"));
274        }
275        if f.detail {
276            assert!(lower.contains("detail"));
277        }
278    }
279
280    // ---- parser: empty / whitespace / separators -------------------------
281
282    #[test]
283    fn parse_empty_input_is_all_off() {
284        assert_eq!(ProfileFlags::parse(""), ProfileFlags::default());
285        assert!(!any_set(&ProfileFlags::parse("")));
286    }
287
288    #[test]
289    fn parse_whitespace_only_is_all_off() {
290        for input in [
291            " ",
292            "   ",
293            "\t",
294            "\n",
295            "\r\n",
296            "\t\n",
297            " \t\r\n\x0c ",
298            "\u{a0}",       // NBSP (Unicode White_Space)
299            "\u{2003}",     // EM SPACE
300            "\u{3000}",     // IDEOGRAPHIC SPACE
301        ] {
302            let f = ProfileFlags::parse(input);
303            assert_eq!(f, ProfileFlags::default(), "input {input:?} set a flag");
304        }
305    }
306
307    #[test]
308    fn parse_separators_only_is_all_off() {
309        for input in [",", ",,", ",,,,,,,,,,", " , , ", "\t,\n,\r", ",,cpu,,"] {
310            let f = ProfileFlags::parse(input);
311            assert_eq!(f.memory, false);
312            assert_eq!(f.cascade, false);
313            assert_eq!(f.heap, false);
314            assert_eq!(f.jsonl, false);
315            assert_eq!(f.detail, false);
316        }
317        // ...but real tokens surrounded by empty ones still register.
318        assert!(ProfileFlags::parse(",,cpu,,").cpu);
319        assert!(!ProfileFlags::parse(",,,,").cpu);
320    }
321
322    // ---- parser: garbage / junk ------------------------------------------
323
324    #[test]
325    fn parse_garbage_never_panics_and_sets_nothing() {
326        for input in [
327            "\0",
328            "\0\0\0",
329            "cpu\0",              // NUL is not whitespace -> not trimmed -> no match
330            "%s%n%s%n",
331            "../../etc/passwd",
332            "{\"cpu\":true}",
333            "-1",
334            "--cpu",
335            "cpu=1",
336            "cpu=true",
337            "CPU;HEAP",           // ';' is not a separator
338            "cpu heap",           // ' ' is not a separator
339            "cpu\tjsonl",
340            "cpux",
341            "xcpu",
342            "cp",
343            "c,p,u",
344            "\u{7f}\u{1}\u{2}",
345            "\\x63\\x70\\x75",
346        ] {
347            let f = ProfileFlags::parse(input);
348            assert_eq!(
349                f,
350                ProfileFlags::default(),
351                "garbage input {input:?} should set no flags, got {f:?}"
352            );
353        }
354    }
355
356    #[test]
357    fn parse_leading_trailing_junk_is_trimmed_or_rejected() {
358        // Surrounding ASCII whitespace is trimmed -> token still matches.
359        assert!(ProfileFlags::parse("  cpu  ").cpu);
360        assert!(ProfileFlags::parse("\t\ncpu\r\n").cpu);
361        assert!(ProfileFlags::parse("  heap ,  jsonl  ").heap);
362        assert!(ProfileFlags::parse("  heap ,  jsonl  ").jsonl);
363
364        // Non-whitespace junk glued to the token is *not* stripped: the token
365        // must match exactly, so "valid;garbage" is rejected wholesale.
366        assert_eq!(ProfileFlags::parse("cpu;garbage"), ProfileFlags::default());
367        assert_eq!(ProfileFlags::parse("garbage;cpu"), ProfileFlags::default());
368        assert_eq!(ProfileFlags::parse("'cpu'"), ProfileFlags::default());
369        assert_eq!(ProfileFlags::parse("\"cpu\""), ProfileFlags::default());
370
371        // ...but a junk *token* next to a valid token only kills itself.
372        let f = ProfileFlags::parse("garbage,cpu,;;;,heap");
373        assert!(f.cpu && f.heap);
374        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
375    }
376
377    // ---- parser: numeric boundaries --------------------------------------
378
379    #[test]
380    fn parse_boundary_numeric_strings_are_ignored() {
381        for input in [
382            "0",
383            "-0",
384            "+0",
385            "1",
386            "9223372036854775807",     // i64::MAX
387            "-9223372036854775808",    // i64::MIN
388            "9223372036854775808",     // i64::MAX + 1
389            "18446744073709551615",    // u64::MAX
390            "18446744073709551616",    // u64::MAX + 1
391            "340282366920938463463374607431768211456",
392            "1.7976931348623157e308",  // f64::MAX
393            "5e-324",                  // f64 min subnormal
394            "1e309",                   // overflows to inf
395            "NaN",
396            "nan",
397            "inf",
398            "-inf",
399            "infinity",
400            "0x7fffffffffffffff",
401            "0b1111",
402            "1e",
403            ".",
404            "..",
405        ] {
406            let f = ProfileFlags::parse(input);
407            assert_eq!(
408                f,
409                ProfileFlags::default(),
410                "numeric-ish input {input:?} should set no flags, got {f:?}"
411            );
412        }
413    }
414
415    #[test]
416    fn parse_numeric_tokens_mixed_with_valid_tokens_do_not_corrupt_flags() {
417        let f = ProfileFlags::parse("NaN,cpu,inf,-0,9223372036854775807,heap,1e309");
418        assert!(f.cpu && f.heap);
419        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
420    }
421
422    // ---- parser: size / nesting limits -----------------------------------
423
424    #[test]
425    fn parse_extremely_long_single_token_does_not_panic_or_hang() {
426        let huge: String = std::iter::repeat_n('a', 1_000_000).collect();
427        assert_eq!(ProfileFlags::parse(&huge), ProfileFlags::default());
428
429        // A 1M-char token that *starts* with a valid token must still not match
430        // (exact equality, not prefix matching).
431        let mut prefixed = String::from("cpu");
432        prefixed.push_str(&huge);
433        assert_eq!(ProfileFlags::parse(&prefixed), ProfileFlags::default());
434    }
435
436    #[test]
437    fn parse_million_separators_does_not_panic_or_hang() {
438        let commas: String = std::iter::repeat_n(',', 1_000_000).collect();
439        assert_eq!(ProfileFlags::parse(&commas), ProfileFlags::default());
440
441        // 1M empty tokens with one real token buried at the end.
442        let mut with_token = commas.clone();
443        with_token.push_str("cpu");
444        assert!(ProfileFlags::parse(&with_token).cpu);
445    }
446
447    #[test]
448    fn parse_repeated_token_250k_times_is_idempotent() {
449        let repeated = "cpu,".repeat(250_000);
450        let f = ProfileFlags::parse(&repeated);
451        assert!(f.cpu);
452        // Repetition is a union, never a toggle: parsing "cpu" 250k times is
453        // the same as parsing it once.
454        assert_eq!(f, ProfileFlags::parse("cpu"));
455    }
456
457    #[test]
458    fn parse_deeply_nested_brackets_does_not_stack_overflow() {
459        let depth = 10_000;
460        let mut nested = String::new();
461        for _ in 0..depth {
462            nested.push('[');
463        }
464        nested.push_str("cpu");
465        for _ in 0..depth {
466            nested.push(']');
467        }
468        // Not a recursive-descent grammar: no recursion, no overflow, and the
469        // bracket-wrapped token does not match.
470        assert_eq!(ProfileFlags::parse(&nested), ProfileFlags::default());
471
472        // Same depth, but comma-separated so every bracket is its own token.
473        let nested_csv = "[,".repeat(depth);
474        assert_eq!(ProfileFlags::parse(&nested_csv), ProfileFlags::default());
475    }
476
477    #[test]
478    fn parse_many_distinct_unknown_tokens_does_not_hang() {
479        let mut s = String::new();
480        for i in 0..100_000u32 {
481            s.push_str(&i.to_string());
482            s.push(',');
483        }
484        s.push_str("detail");
485        let f = ProfileFlags::parse(&s);
486        assert!(f.detail);
487        assert!(!f.cpu && !f.memory && !f.cascade && !f.heap && !f.jsonl);
488    }
489
490    // ---- parser: unicode --------------------------------------------------
491
492    #[test]
493    fn parse_unicode_does_not_panic_and_matches_exactly() {
494        // Multibyte junk: never matches, never panics on a char boundary.
495        for input in [
496            "\u{1F600}",                 // emoji
497            "\u{1F600},\u{1F4A9}",
498            "cpu\u{301}",                // "cpu" + combining acute -> different token
499            "\u{301}cpu",
500            "\u{feff}cpu",               // BOM is NOT Unicode White_Space -> not trimmed
501            "cpu",                     // fullwidth latin
502            "СРU",                       // Cyrillic С/Р homoglyphs
503            "cpü",
504            "HEAP",
505            "日本語,中文,한국어",
506            "\u{202e}cpu",               // RTL override
507            "e\u{301}\u{301}\u{301}",
508        ] {
509            let f = ProfileFlags::parse(input);
510            assert_eq!(
511                f,
512                ProfileFlags::default(),
513                "unicode input {input:?} should set no flags, got {f:?}"
514            );
515        }
516    }
517
518    #[test]
519    fn parse_trims_unicode_whitespace_around_ascii_tokens() {
520        // `str::trim` uses the Unicode White_Space property, so NBSP / EM SPACE
521        // / IDEOGRAPHIC SPACE are stripped just like ASCII spaces.
522        assert!(ProfileFlags::parse("\u{a0}cpu\u{a0}").cpu);
523        assert!(ProfileFlags::parse("\u{2003}heap\u{2003}").heap);
524        assert!(ProfileFlags::parse("\u{3000}jsonl").jsonl);
525    }
526
527    #[test]
528    fn parse_unicode_mixed_with_valid_tokens_keeps_valid_ones() {
529        let f = ProfileFlags::parse("\u{1F600},cpu,日本語,heap,\u{202e}");
530        assert!(f.cpu && f.heap);
531        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
532    }
533
534    // ---- parser: positive controls & invariants ---------------------------
535
536    #[test]
537    fn parse_each_canonical_token_sets_exactly_one_flag() {
538        for (idx, tok) in CANONICAL.iter().enumerate() {
539            let f = ProfileFlags::parse(tok);
540            assert!(field(&f, idx), "token {tok:?} did not set its own flag");
541            let leaked = (0..CANONICAL.len())
542                .filter(|other| *other != idx)
543                .filter(|other| field(&f, *other))
544                .count();
545            assert_eq!(leaked, 0, "token {tok:?} leaked into another flag: {f:?}");
546        }
547    }
548
549    #[test]
550    fn parse_each_alias_is_equivalent_to_its_canonical_token() {
551        for (alias, canonical) in ALIASES {
552            assert_eq!(
553                ProfileFlags::parse(alias),
554                ProfileFlags::parse(canonical),
555                "alias {alias:?} != canonical {canonical:?}"
556            );
557        }
558    }
559
560    #[test]
561    fn parse_is_case_insensitive_for_every_token() {
562        for (idx, tok) in CANONICAL.iter().enumerate() {
563            for variant in [tok.to_ascii_uppercase(), tok.to_ascii_lowercase()] {
564                let f = ProfileFlags::parse(&variant);
565                assert!(field(&f, idx), "case variant {variant:?} did not match");
566            }
567        }
568        let all = ProfileFlags::parse("MEMORY,CPU,CaScAdE,HeAp,jSoNl,DETAIL");
569        assert_eq!(all, from_mask(0b11_1111));
570    }
571
572    #[test]
573    fn parse_is_order_independent() {
574        let a = ProfileFlags::parse("cpu,heap,detail");
575        let b = ProfileFlags::parse("detail,heap,cpu");
576        let c = ProfileFlags::parse("heap,detail,cpu");
577        assert_eq!(a, b);
578        assert_eq!(b, c);
579    }
580
581    #[test]
582    fn parse_is_monotone_unknown_tokens_never_unset_a_flag() {
583        let base = ProfileFlags::parse("cpu,heap");
584        for junk in ["bogus", "", "   ", "\u{1F600}", "NaN", "-cpu", "heap;"] {
585            let mut with_junk = String::from("cpu,heap,");
586            with_junk.push_str(junk);
587            let f = ProfileFlags::parse(&with_junk);
588            assert!(f.cpu && f.heap, "junk {junk:?} cleared a flag: {f:?}");
589            assert_eq!(f, base, "junk {junk:?} changed the flag set");
590        }
591    }
592
593    #[test]
594    fn parse_jsonl_does_not_implicitly_enable_heap() {
595        // The docs say `jsonl` "requires heap to do anything" — that dependency
596        // is *not* enforced at parse time, and this test pins that down.
597        let f = ProfileFlags::parse("jsonl");
598        assert!(f.jsonl);
599        assert!(!f.heap);
600
601        // Same for `detail`, which layers on top of `heap`.
602        let d = ProfileFlags::parse("detail");
603        assert!(d.detail);
604        assert!(!d.heap);
605    }
606
607    // ---- round-trip: encode == decode ------------------------------------
608
609    #[test]
610    fn round_trip_all_64_flag_combinations() {
611        for mask in 0..64u8 {
612            let original = from_mask(mask);
613            let encoded = encode(original);
614            let decoded = ProfileFlags::parse(&encoded);
615            assert_eq!(
616                decoded, original,
617                "round-trip failed for mask {mask:#08b} (encoded {encoded:?})"
618            );
619        }
620    }
621
622    #[test]
623    fn round_trip_survives_whitespace_and_case_mangling() {
624        for mask in 0..64u8 {
625            let original = from_mask(mask);
626            let encoded = encode(original);
627            // Re-render as " TOKEN , TOKEN " in upper case with padding.
628            let mangled: Vec<String> = encoded
629                .split(',')
630                .filter(|s| !s.is_empty())
631                .map(|t| {
632                    let mut s = String::from("  ");
633                    s.push_str(&t.to_ascii_uppercase());
634                    s.push_str(" \t");
635                    s
636                })
637                .collect();
638            let decoded = ProfileFlags::parse(&mangled.join(","));
639            assert_eq!(decoded, original, "mangled round-trip failed for {mask:#08b}");
640        }
641    }
642
643    #[test]
644    fn round_trip_is_stable_under_re_encoding() {
645        for mask in 0..64u8 {
646            let f = from_mask(mask);
647            let once = encode(f);
648            let twice = encode(ProfileFlags::parse(&once));
649            assert_eq!(once, twice, "encode is not a fixed point for {mask:#08b}");
650        }
651    }
652
653    // ---- deterministic pseudo-random fuzz --------------------------------
654
655    #[test]
656    fn parse_fuzz_is_deterministic_and_never_invents_flags() {
657        const PIECES: [&str; 24] = [
658            "cpu", "CPU", "mem", "memory", "cascade", "css", "heap", "jsonl", "detail", "perf",
659            ",", ";", " ", "\t", "\n", "", "x", "0", "NaN", "\u{1F600}", "\u{301}", "\u{a0}", "=",
660            "-",
661        ];
662
663        // Fixed-seed LCG: no Math.random / wall-clock, fully reproducible.
664        let mut state: u64 = 0x2545_F491_4F6C_DD1D;
665        let mut next = move || {
666            state = state
667                .wrapping_mul(6_364_136_223_846_793_005)
668                .wrapping_add(1_442_695_040_888_963_407);
669            (state >> 33) as usize
670        };
671
672        for _ in 0..2_000 {
673            let len = next() % 24;
674            let mut input = String::new();
675            for _ in 0..len {
676                input.push_str(PIECES[next() % PIECES.len()]);
677            }
678
679            let f = ProfileFlags::parse(&input);
680            // 1. deterministic
681            assert_eq!(f, ProfileFlags::parse(&input), "parse is not deterministic");
682            // 2. never sets a flag whose token isn't present
683            assert_flags_are_justified(&input, &f);
684            // 3. a completely token-free input never sets anything
685            let lower = input.to_ascii_lowercase();
686            if !["memory", "mem", "cpu", "perf", "cascade", "css", "heap", "jsonl", "detail"]
687                .iter()
688                .any(|t| lower.contains(t))
689            {
690                assert_eq!(f, ProfileFlags::default(), "flags set for {input:?}");
691            }
692        }
693    }
694
695    // ---- flags() / out_path() / predicates --------------------------------
696
697    #[test]
698    fn default_flags_are_all_off() {
699        let d = ProfileFlags::default();
700        assert!(!any_set(&d));
701        assert_eq!(d, ProfileFlags::parse(""));
702    }
703
704    #[test]
705    fn flags_is_cached_and_stable_across_calls() {
706        // NOTE: the env var is deliberately *not* mutated here — `flags()` is a
707        // process-wide `OnceLock` and tests run in parallel threads, so any
708        // `set_var` would be both racy and useless after first access.
709        let first = flags();
710        for _ in 0..1_000 {
711            assert_eq!(flags(), first, "flags() is not stable across calls");
712        }
713    }
714
715    #[test]
716    fn predicates_agree_with_flags() {
717        let f = flags();
718        assert_eq!(memory_enabled(), f.memory);
719        assert_eq!(cpu_enabled(), f.cpu);
720        assert_eq!(cascade_enabled(), f.cascade);
721        assert_eq!(heap_enabled(), f.heap);
722        assert_eq!(jsonl_enabled(), f.jsonl);
723        assert_eq!(detail_enabled(), f.detail);
724    }
725
726    #[test]
727    fn predicates_are_idempotent() {
728        for _ in 0..100 {
729            assert_eq!(memory_enabled(), memory_enabled());
730            assert_eq!(cpu_enabled(), cpu_enabled());
731            assert_eq!(cascade_enabled(), cascade_enabled());
732            assert_eq!(heap_enabled(), heap_enabled());
733            assert_eq!(jsonl_enabled(), jsonl_enabled());
734            assert_eq!(detail_enabled(), detail_enabled());
735        }
736    }
737
738    #[test]
739    fn out_path_does_not_panic_and_is_cached() {
740        let first = out_path();
741        for _ in 0..100 {
742            assert_eq!(out_path(), first, "out_path() is not stable across calls");
743        }
744        // If a path *is* configured it must be a real (possibly empty) &'static
745        // str handed back from the same cached allocation every time.
746        if let Some(p) = first {
747            assert_eq!(out_path().map(str::as_ptr), Some(p.as_ptr()));
748        }
749    }
750
751    /// `no_std` builds have no environment: profiling must be hard-off.
752    #[cfg(not(feature = "std"))]
753    #[test]
754    fn nostd_profiling_is_always_off() {
755        assert_eq!(flags(), ProfileFlags::default());
756        assert_eq!(out_path(), None);
757        assert!(!memory_enabled());
758        assert!(!cpu_enabled());
759        assert!(!cascade_enabled());
760        assert!(!heap_enabled());
761        assert!(!jsonl_enabled());
762        assert!(!detail_enabled());
763    }
764
765    /// Under `std`, `flags()` must agree with whatever `AZ_PROFILE` said at
766    /// first access — and, crucially, must keep agreeing even if the env var is
767    /// later changed by some other part of the process (it is cached forever).
768    #[cfg(feature = "std")]
769    #[test]
770    fn std_flags_match_env_or_default_and_never_change() {
771        let observed = flags();
772        let expected_now = std::env::var("AZ_PROFILE")
773            .map(|v| ProfileFlags::parse(&v))
774            .unwrap_or_default();
775        // The cache is filled on first access; within one test binary the env
776        // var is not mutated, so these must agree.
777        assert_eq!(observed, expected_now);
778        assert_eq!(flags(), observed);
779    }
780}