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")]
54
use 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)]
61
pub 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

            
70
impl ProfileFlags {
71
4333
    fn parse(value: &str) -> Self {
72
4333
        let mut f = Self::default();
73
2366674
        for tok in value.split(',') {
74
2366674
            let t = tok.trim();
75
2366674
            if t.eq_ignore_ascii_case("memory") || t.eq_ignore_ascii_case("mem") {
76
179
                f.memory = true;
77
2366495
            } else if t.eq_ignore_ascii_case("cpu") || t.eq_ignore_ascii_case("perf") {
78
250254
                f.cpu = true;
79
2116241
            } else if t.eq_ignore_ascii_case("cascade") || t.eq_ignore_ascii_case("css") {
80
171
                f.cascade = true;
81
2116070
            } else if t.eq_ignore_ascii_case("heap") {
82
152
                f.heap = true;
83
2115918
            } else if t.eq_ignore_ascii_case("jsonl") {
84
136
                f.jsonl = true;
85
2115782
            } else if t.eq_ignore_ascii_case("detail") {
86
130
                f.detail = true;
87
2115652
            }
88
        }
89
4333
        f
90
4333
    }
91
}
92

            
93
#[cfg(feature = "std")]
94
#[inline]
95
2904
pub fn flags() -> ProfileFlags {
96
    static FLAGS: OnceLock<ProfileFlags> = OnceLock::new();
97
2904
    *FLAGS.get_or_init(|| {
98
190
        let f = std::env::var("AZ_PROFILE")
99
190
            .map(|v| ProfileFlags::parse(&v))
100
190
            .unwrap_or_default();
101
        // The announce table: a profile mode that silently emits NOTHING
102
        // reads as "not looking" and has repeatedly burned real debugging
103
        // time ("a zero is not a measurement"). One line, once, at the
104
        // single point every mode resolves through.
105
190
        if f.heap && !f.jsonl {
106
            eprintln!(
107
                "[azul][profile] AZ_PROFILE=heap alone emits nothing: use \
108
                 AZ_PROFILE=heap,jsonl with AZ_PROFILE_OUT=<file> for the \
109
                 per-phase heap table (and note builds without the `probe` \
110
                 feature report heap as 0)."
111
            );
112
190
        }
113
190
        if f.heap && f.jsonl && std::env::var("AZ_PROFILE_OUT").is_err() {
114
            eprintln!(
115
                "[azul][profile] AZ_PROFILE=heap,jsonl is set but \
116
                 AZ_PROFILE_OUT is not — no destination, nothing will be \
117
                 written."
118
            );
119
190
        }
120
190
        f
121
190
    })
122
2904
}
123

            
124
/// `no_std` builds have no environment; profiling is always off.
125
#[cfg(not(feature = "std"))]
126
#[inline]
127
pub fn flags() -> ProfileFlags {
128
    let _ = ProfileFlags::parse;
129
    ProfileFlags::default()
130
}
131

            
132
/// `AZ_PROFILE_OUT=<path>` — destination for JSONL heap probes.
133
/// Returns `None` if unset. Cached on first access.
134
#[cfg(feature = "std")]
135
#[inline]
136
101
pub fn out_path() -> Option<&'static str> {
137
    static PATH: OnceLock<Option<String>> = OnceLock::new();
138
101
    PATH.get_or_init(|| std::env::var("AZ_PROFILE_OUT").ok())
139
101
        .as_deref()
140
101
}
141

            
142
/// `no_std` builds have no environment; no output path.
143
#[cfg(not(feature = "std"))]
144
#[inline]
145
pub fn out_path() -> Option<&'static str> {
146
    None
147
}
148

            
149
#[inline]
150
683
#[must_use] pub fn memory_enabled() -> bool { flags().memory }
151

            
152
#[inline]
153
242
#[must_use] pub fn cpu_enabled() -> bool { flags().cpu }
154

            
155
#[inline]
156
357
#[must_use] pub fn cascade_enabled() -> bool { flags().cascade }
157

            
158
#[inline]
159
202
#[must_use] pub fn heap_enabled() -> bool { flags().heap }
160

            
161
#[inline]
162
201
#[must_use] pub fn jsonl_enabled() -> bool { flags().jsonl }
163

            
164
#[inline]
165
201
#[must_use] pub fn detail_enabled() -> bool { flags().detail }
166

            
167
#[cfg(test)]
168
mod tests {
169
    use super::ProfileFlags;
170

            
171
    #[test]
172
1
    fn parse_single_token() {
173
1
        let f = ProfileFlags::parse("cpu");
174
1
        assert!(f.cpu && !f.memory && !f.heap);
175
1
    }
176

            
177
    #[test]
178
1
    fn parse_multiple_tokens() {
179
1
        let f = ProfileFlags::parse("heap,jsonl,detail");
180
1
        assert!(f.heap && f.jsonl && f.detail);
181
1
        assert!(!f.cpu && !f.memory);
182
1
    }
183

            
184
    #[test]
185
1
    fn parse_is_case_insensitive_and_trims() {
186
1
        let f = ProfileFlags::parse(" Heap , JSONL ");
187
1
        assert!(f.heap && f.jsonl);
188
1
    }
189

            
190
    #[test]
191
1
    fn parse_ignores_unknown_tokens() {
192
1
        let f = ProfileFlags::parse("cpu,bogus,heap");
193
1
        assert!(f.cpu && f.heap);
194
1
    }
195

            
196
    #[test]
197
1
    fn parse_accepts_aliases() {
198
1
        let f = ProfileFlags::parse("mem,perf,css");
199
1
        assert!(f.memory && f.cpu && f.cascade);
200
1
    }
201
}
202

            
203
#[cfg(test)]
204
#[allow(clippy::bool_assert_comparison)]
205
mod autotest_generated {
206
    use alloc::{
207
        string::{String, ToString},
208
        vec::Vec,
209
    };
210

            
211
    use super::*;
212

            
213
    // ---- helpers ---------------------------------------------------------
214

            
215
    /// Canonical token for every flag, in field-declaration order.
216
    const CANONICAL: [&str; 6] = ["memory", "cpu", "cascade", "heap", "jsonl", "detail"];
217

            
218
    /// The documented aliases, paired with the canonical token they mean.
219
    const ALIASES: [(&str, &str); 3] = [("mem", "memory"), ("perf", "cpu"), ("css", "cascade")];
220

            
221
    /// Read field `idx` of a flag set, indices matching `CANONICAL`.
222
    fn field(f: &ProfileFlags, idx: usize) -> bool {
223
        match idx {
224
            0 => f.memory,
225
            1 => f.cpu,
226
            2 => f.cascade,
227
            3 => f.heap,
228
            4 => f.jsonl,
229
            5 => f.detail,
230
            _ => unreachable!("CANONICAL has 6 entries"),
231
        }
232
    }
233

            
234
    /// Inverse of `ProfileFlags::parse`: render a flag set as an `AZ_PROFILE`
235
    /// value. Used for the encode/decode round-trip below.
236
    fn encode(f: ProfileFlags) -> String {
237
        let mut parts: Vec<&str> = Vec::new();
238
        if f.memory {
239
            parts.push("memory");
240
        }
241
        if f.cpu {
242
            parts.push("cpu");
243
        }
244
        if f.cascade {
245
            parts.push("cascade");
246
        }
247
        if f.heap {
248
            parts.push("heap");
249
        }
250
        if f.jsonl {
251
            parts.push("jsonl");
252
        }
253
        if f.detail {
254
            parts.push("detail");
255
        }
256
        parts.join(",")
257
    }
258

            
259
    /// Build a flag set from a 6-bit mask (bit i == field i in `CANONICAL`).
260
    fn from_mask(mask: u8) -> ProfileFlags {
261
        ProfileFlags {
262
            memory: mask & 0b00_0001 != 0,
263
            cpu: mask & 0b00_0010 != 0,
264
            cascade: mask & 0b00_0100 != 0,
265
            heap: mask & 0b00_1000 != 0,
266
            jsonl: mask & 0b01_0000 != 0,
267
            detail: mask & 0b10_0000 != 0,
268
        }
269
    }
270

            
271
    fn any_set(f: &ProfileFlags) -> bool {
272
        f.memory || f.cpu || f.cascade || f.heap || f.jsonl || f.detail
273
    }
274

            
275
    /// Core soundness invariant: a flag can only be set if the token that
276
    /// enables it literally occurs in the input (ASCII-case-insensitively).
277
    /// `parse` never invents flags out of thin air.
278
    fn assert_flags_are_justified(input: &str, f: &ProfileFlags) {
279
        let lower = input.to_ascii_lowercase();
280
        if f.memory {
281
            assert!(lower.contains("memory") || lower.contains("mem"));
282
        }
283
        if f.cpu {
284
            assert!(lower.contains("cpu") || lower.contains("perf"));
285
        }
286
        if f.cascade {
287
            assert!(lower.contains("cascade") || lower.contains("css"));
288
        }
289
        if f.heap {
290
            assert!(lower.contains("heap"));
291
        }
292
        if f.jsonl {
293
            assert!(lower.contains("jsonl"));
294
        }
295
        if f.detail {
296
            assert!(lower.contains("detail"));
297
        }
298
    }
299

            
300
    // ---- parser: empty / whitespace / separators -------------------------
301

            
302
    #[test]
303
    fn parse_empty_input_is_all_off() {
304
        assert_eq!(ProfileFlags::parse(""), ProfileFlags::default());
305
        assert!(!any_set(&ProfileFlags::parse("")));
306
    }
307

            
308
    #[test]
309
    fn parse_whitespace_only_is_all_off() {
310
        for input in [
311
            " ",
312
            "   ",
313
            "\t",
314
            "\n",
315
            "\r\n",
316
            "\t\n",
317
            " \t\r\n\x0c ",
318
            "\u{a0}",       // NBSP (Unicode White_Space)
319
            "\u{2003}",     // EM SPACE
320
            "\u{3000}",     // IDEOGRAPHIC SPACE
321
        ] {
322
            let f = ProfileFlags::parse(input);
323
            assert_eq!(f, ProfileFlags::default(), "input {input:?} set a flag");
324
        }
325
    }
326

            
327
    #[test]
328
    fn parse_separators_only_is_all_off() {
329
        for input in [",", ",,", ",,,,,,,,,,", " , , ", "\t,\n,\r", ",,cpu,,"] {
330
            let f = ProfileFlags::parse(input);
331
            assert_eq!(f.memory, false);
332
            assert_eq!(f.cascade, false);
333
            assert_eq!(f.heap, false);
334
            assert_eq!(f.jsonl, false);
335
            assert_eq!(f.detail, false);
336
        }
337
        // ...but real tokens surrounded by empty ones still register.
338
        assert!(ProfileFlags::parse(",,cpu,,").cpu);
339
        assert!(!ProfileFlags::parse(",,,,").cpu);
340
    }
341

            
342
    // ---- parser: garbage / junk ------------------------------------------
343

            
344
    #[test]
345
    fn parse_garbage_never_panics_and_sets_nothing() {
346
        for input in [
347
            "\0",
348
            "\0\0\0",
349
            "cpu\0",              // NUL is not whitespace -> not trimmed -> no match
350
            "%s%n%s%n",
351
            "../../etc/passwd",
352
            "{\"cpu\":true}",
353
            "-1",
354
            "--cpu",
355
            "cpu=1",
356
            "cpu=true",
357
            "CPU;HEAP",           // ';' is not a separator
358
            "cpu heap",           // ' ' is not a separator
359
            "cpu\tjsonl",
360
            "cpux",
361
            "xcpu",
362
            "cp",
363
            "c,p,u",
364
            "\u{7f}\u{1}\u{2}",
365
            "\\x63\\x70\\x75",
366
        ] {
367
            let f = ProfileFlags::parse(input);
368
            assert_eq!(
369
                f,
370
                ProfileFlags::default(),
371
                "garbage input {input:?} should set no flags, got {f:?}"
372
            );
373
        }
374
    }
375

            
376
    #[test]
377
    fn parse_leading_trailing_junk_is_trimmed_or_rejected() {
378
        // Surrounding ASCII whitespace is trimmed -> token still matches.
379
        assert!(ProfileFlags::parse("  cpu  ").cpu);
380
        assert!(ProfileFlags::parse("\t\ncpu\r\n").cpu);
381
        assert!(ProfileFlags::parse("  heap ,  jsonl  ").heap);
382
        assert!(ProfileFlags::parse("  heap ,  jsonl  ").jsonl);
383

            
384
        // Non-whitespace junk glued to the token is *not* stripped: the token
385
        // must match exactly, so "valid;garbage" is rejected wholesale.
386
        assert_eq!(ProfileFlags::parse("cpu;garbage"), ProfileFlags::default());
387
        assert_eq!(ProfileFlags::parse("garbage;cpu"), ProfileFlags::default());
388
        assert_eq!(ProfileFlags::parse("'cpu'"), ProfileFlags::default());
389
        assert_eq!(ProfileFlags::parse("\"cpu\""), ProfileFlags::default());
390

            
391
        // ...but a junk *token* next to a valid token only kills itself.
392
        let f = ProfileFlags::parse("garbage,cpu,;;;,heap");
393
        assert!(f.cpu && f.heap);
394
        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
395
    }
396

            
397
    // ---- parser: numeric boundaries --------------------------------------
398

            
399
    #[test]
400
    fn parse_boundary_numeric_strings_are_ignored() {
401
        for input in [
402
            "0",
403
            "-0",
404
            "+0",
405
            "1",
406
            "9223372036854775807",     // i64::MAX
407
            "-9223372036854775808",    // i64::MIN
408
            "9223372036854775808",     // i64::MAX + 1
409
            "18446744073709551615",    // u64::MAX
410
            "18446744073709551616",    // u64::MAX + 1
411
            "340282366920938463463374607431768211456",
412
            "1.7976931348623157e308",  // f64::MAX
413
            "5e-324",                  // f64 min subnormal
414
            "1e309",                   // overflows to inf
415
            "NaN",
416
            "nan",
417
            "inf",
418
            "-inf",
419
            "infinity",
420
            "0x7fffffffffffffff",
421
            "0b1111",
422
            "1e",
423
            ".",
424
            "..",
425
        ] {
426
            let f = ProfileFlags::parse(input);
427
            assert_eq!(
428
                f,
429
                ProfileFlags::default(),
430
                "numeric-ish input {input:?} should set no flags, got {f:?}"
431
            );
432
        }
433
    }
434

            
435
    #[test]
436
    fn parse_numeric_tokens_mixed_with_valid_tokens_do_not_corrupt_flags() {
437
        let f = ProfileFlags::parse("NaN,cpu,inf,-0,9223372036854775807,heap,1e309");
438
        assert!(f.cpu && f.heap);
439
        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
440
    }
441

            
442
    // ---- parser: size / nesting limits -----------------------------------
443

            
444
    #[test]
445
    fn parse_extremely_long_single_token_does_not_panic_or_hang() {
446
        let huge: String = std::iter::repeat_n('a', 1_000_000).collect();
447
        assert_eq!(ProfileFlags::parse(&huge), ProfileFlags::default());
448

            
449
        // A 1M-char token that *starts* with a valid token must still not match
450
        // (exact equality, not prefix matching).
451
        let mut prefixed = String::from("cpu");
452
        prefixed.push_str(&huge);
453
        assert_eq!(ProfileFlags::parse(&prefixed), ProfileFlags::default());
454
    }
455

            
456
    #[test]
457
    fn parse_million_separators_does_not_panic_or_hang() {
458
        let commas: String = std::iter::repeat_n(',', 1_000_000).collect();
459
        assert_eq!(ProfileFlags::parse(&commas), ProfileFlags::default());
460

            
461
        // 1M empty tokens with one real token buried at the end.
462
        let mut with_token = commas.clone();
463
        with_token.push_str("cpu");
464
        assert!(ProfileFlags::parse(&with_token).cpu);
465
    }
466

            
467
    #[test]
468
    fn parse_repeated_token_250k_times_is_idempotent() {
469
        let repeated = "cpu,".repeat(250_000);
470
        let f = ProfileFlags::parse(&repeated);
471
        assert!(f.cpu);
472
        // Repetition is a union, never a toggle: parsing "cpu" 250k times is
473
        // the same as parsing it once.
474
        assert_eq!(f, ProfileFlags::parse("cpu"));
475
    }
476

            
477
    #[test]
478
    fn parse_deeply_nested_brackets_does_not_stack_overflow() {
479
        let depth = 10_000;
480
        let mut nested = String::new();
481
        for _ in 0..depth {
482
            nested.push('[');
483
        }
484
        nested.push_str("cpu");
485
        for _ in 0..depth {
486
            nested.push(']');
487
        }
488
        // Not a recursive-descent grammar: no recursion, no overflow, and the
489
        // bracket-wrapped token does not match.
490
        assert_eq!(ProfileFlags::parse(&nested), ProfileFlags::default());
491

            
492
        // Same depth, but comma-separated so every bracket is its own token.
493
        let nested_csv = "[,".repeat(depth);
494
        assert_eq!(ProfileFlags::parse(&nested_csv), ProfileFlags::default());
495
    }
496

            
497
    #[test]
498
    fn parse_many_distinct_unknown_tokens_does_not_hang() {
499
        let mut s = String::new();
500
        for i in 0..100_000u32 {
501
            s.push_str(&i.to_string());
502
            s.push(',');
503
        }
504
        s.push_str("detail");
505
        let f = ProfileFlags::parse(&s);
506
        assert!(f.detail);
507
        assert!(!f.cpu && !f.memory && !f.cascade && !f.heap && !f.jsonl);
508
    }
509

            
510
    // ---- parser: unicode --------------------------------------------------
511

            
512
    #[test]
513
    fn parse_unicode_does_not_panic_and_matches_exactly() {
514
        // Multibyte junk: never matches, never panics on a char boundary.
515
        for input in [
516
            "\u{1F600}",                 // emoji
517
            "\u{1F600},\u{1F4A9}",
518
            "cpu\u{301}",                // "cpu" + combining acute -> different token
519
            "\u{301}cpu",
520
            "\u{feff}cpu",               // BOM is NOT Unicode White_Space -> not trimmed
521
            "cpu",                     // fullwidth latin
522
            "СРU",                       // Cyrillic С/Р homoglyphs
523
            "cpü",
524
            "HEAP",
525
            "日本語,中文,한국어",
526
            "\u{202e}cpu",               // RTL override
527
            "e\u{301}\u{301}\u{301}",
528
        ] {
529
            let f = ProfileFlags::parse(input);
530
            assert_eq!(
531
                f,
532
                ProfileFlags::default(),
533
                "unicode input {input:?} should set no flags, got {f:?}"
534
            );
535
        }
536
    }
537

            
538
    #[test]
539
    fn parse_trims_unicode_whitespace_around_ascii_tokens() {
540
        // `str::trim` uses the Unicode White_Space property, so NBSP / EM SPACE
541
        // / IDEOGRAPHIC SPACE are stripped just like ASCII spaces.
542
        assert!(ProfileFlags::parse("\u{a0}cpu\u{a0}").cpu);
543
        assert!(ProfileFlags::parse("\u{2003}heap\u{2003}").heap);
544
        assert!(ProfileFlags::parse("\u{3000}jsonl").jsonl);
545
    }
546

            
547
    #[test]
548
    fn parse_unicode_mixed_with_valid_tokens_keeps_valid_ones() {
549
        let f = ProfileFlags::parse("\u{1F600},cpu,日本語,heap,\u{202e}");
550
        assert!(f.cpu && f.heap);
551
        assert!(!f.memory && !f.cascade && !f.jsonl && !f.detail);
552
    }
553

            
554
    // ---- parser: positive controls & invariants ---------------------------
555

            
556
    #[test]
557
    fn parse_each_canonical_token_sets_exactly_one_flag() {
558
        for (idx, tok) in CANONICAL.iter().enumerate() {
559
            let f = ProfileFlags::parse(tok);
560
            assert!(field(&f, idx), "token {tok:?} did not set its own flag");
561
            let leaked = (0..CANONICAL.len())
562
                .filter(|other| *other != idx)
563
                .filter(|other| field(&f, *other))
564
                .count();
565
            assert_eq!(leaked, 0, "token {tok:?} leaked into another flag: {f:?}");
566
        }
567
    }
568

            
569
    #[test]
570
    fn parse_each_alias_is_equivalent_to_its_canonical_token() {
571
        for (alias, canonical) in ALIASES {
572
            assert_eq!(
573
                ProfileFlags::parse(alias),
574
                ProfileFlags::parse(canonical),
575
                "alias {alias:?} != canonical {canonical:?}"
576
            );
577
        }
578
    }
579

            
580
    #[test]
581
    fn parse_is_case_insensitive_for_every_token() {
582
        for (idx, tok) in CANONICAL.iter().enumerate() {
583
            for variant in [tok.to_ascii_uppercase(), tok.to_ascii_lowercase()] {
584
                let f = ProfileFlags::parse(&variant);
585
                assert!(field(&f, idx), "case variant {variant:?} did not match");
586
            }
587
        }
588
        let all = ProfileFlags::parse("MEMORY,CPU,CaScAdE,HeAp,jSoNl,DETAIL");
589
        assert_eq!(all, from_mask(0b11_1111));
590
    }
591

            
592
    #[test]
593
    fn parse_is_order_independent() {
594
        let a = ProfileFlags::parse("cpu,heap,detail");
595
        let b = ProfileFlags::parse("detail,heap,cpu");
596
        let c = ProfileFlags::parse("heap,detail,cpu");
597
        assert_eq!(a, b);
598
        assert_eq!(b, c);
599
    }
600

            
601
    #[test]
602
    fn parse_is_monotone_unknown_tokens_never_unset_a_flag() {
603
        let base = ProfileFlags::parse("cpu,heap");
604
        for junk in ["bogus", "", "   ", "\u{1F600}", "NaN", "-cpu", "heap;"] {
605
            let mut with_junk = String::from("cpu,heap,");
606
            with_junk.push_str(junk);
607
            let f = ProfileFlags::parse(&with_junk);
608
            assert!(f.cpu && f.heap, "junk {junk:?} cleared a flag: {f:?}");
609
            assert_eq!(f, base, "junk {junk:?} changed the flag set");
610
        }
611
    }
612

            
613
    #[test]
614
    fn parse_jsonl_does_not_implicitly_enable_heap() {
615
        // The docs say `jsonl` "requires heap to do anything" — that dependency
616
        // is *not* enforced at parse time, and this test pins that down.
617
        let f = ProfileFlags::parse("jsonl");
618
        assert!(f.jsonl);
619
        assert!(!f.heap);
620

            
621
        // Same for `detail`, which layers on top of `heap`.
622
        let d = ProfileFlags::parse("detail");
623
        assert!(d.detail);
624
        assert!(!d.heap);
625
    }
626

            
627
    // ---- round-trip: encode == decode ------------------------------------
628

            
629
    #[test]
630
    fn round_trip_all_64_flag_combinations() {
631
        for mask in 0..64u8 {
632
            let original = from_mask(mask);
633
            let encoded = encode(original);
634
            let decoded = ProfileFlags::parse(&encoded);
635
            assert_eq!(
636
                decoded, original,
637
                "round-trip failed for mask {mask:#08b} (encoded {encoded:?})"
638
            );
639
        }
640
    }
641

            
642
    #[test]
643
    fn round_trip_survives_whitespace_and_case_mangling() {
644
        for mask in 0..64u8 {
645
            let original = from_mask(mask);
646
            let encoded = encode(original);
647
            // Re-render as " TOKEN , TOKEN " in upper case with padding.
648
            let mangled: Vec<String> = encoded
649
                .split(',')
650
                .filter(|s| !s.is_empty())
651
                .map(|t| {
652
                    let mut s = String::from("  ");
653
                    s.push_str(&t.to_ascii_uppercase());
654
                    s.push_str(" \t");
655
                    s
656
                })
657
                .collect();
658
            let decoded = ProfileFlags::parse(&mangled.join(","));
659
            assert_eq!(decoded, original, "mangled round-trip failed for {mask:#08b}");
660
        }
661
    }
662

            
663
    #[test]
664
    fn round_trip_is_stable_under_re_encoding() {
665
        for mask in 0..64u8 {
666
            let f = from_mask(mask);
667
            let once = encode(f);
668
            let twice = encode(ProfileFlags::parse(&once));
669
            assert_eq!(once, twice, "encode is not a fixed point for {mask:#08b}");
670
        }
671
    }
672

            
673
    // ---- deterministic pseudo-random fuzz --------------------------------
674

            
675
    #[test]
676
    fn parse_fuzz_is_deterministic_and_never_invents_flags() {
677
        const PIECES: [&str; 24] = [
678
            "cpu", "CPU", "mem", "memory", "cascade", "css", "heap", "jsonl", "detail", "perf",
679
            ",", ";", " ", "\t", "\n", "", "x", "0", "NaN", "\u{1F600}", "\u{301}", "\u{a0}", "=",
680
            "-",
681
        ];
682

            
683
        // Fixed-seed LCG: no Math.random / wall-clock, fully reproducible.
684
        let mut state: u64 = 0x2545_F491_4F6C_DD1D;
685
        let mut next = move || {
686
            state = state
687
                .wrapping_mul(6_364_136_223_846_793_005)
688
                .wrapping_add(1_442_695_040_888_963_407);
689
            (state >> 33) as usize
690
        };
691

            
692
        for _ in 0..2_000 {
693
            let len = next() % 24;
694
            let mut input = String::new();
695
            for _ in 0..len {
696
                input.push_str(PIECES[next() % PIECES.len()]);
697
            }
698

            
699
            let f = ProfileFlags::parse(&input);
700
            // 1. deterministic
701
            assert_eq!(f, ProfileFlags::parse(&input), "parse is not deterministic");
702
            // 2. never sets a flag whose token isn't present
703
            assert_flags_are_justified(&input, &f);
704
            // 3. a completely token-free input never sets anything
705
            let lower = input.to_ascii_lowercase();
706
            if !["memory", "mem", "cpu", "perf", "cascade", "css", "heap", "jsonl", "detail"]
707
                .iter()
708
                .any(|t| lower.contains(t))
709
            {
710
                assert_eq!(f, ProfileFlags::default(), "flags set for {input:?}");
711
            }
712
        }
713
    }
714

            
715
    // ---- flags() / out_path() / predicates --------------------------------
716

            
717
    #[test]
718
    fn default_flags_are_all_off() {
719
        let d = ProfileFlags::default();
720
        assert!(!any_set(&d));
721
        assert_eq!(d, ProfileFlags::parse(""));
722
    }
723

            
724
    #[test]
725
    fn flags_is_cached_and_stable_across_calls() {
726
        // NOTE: the env var is deliberately *not* mutated here — `flags()` is a
727
        // process-wide `OnceLock` and tests run in parallel threads, so any
728
        // `set_var` would be both racy and useless after first access.
729
        let first = flags();
730
        for _ in 0..1_000 {
731
            assert_eq!(flags(), first, "flags() is not stable across calls");
732
        }
733
    }
734

            
735
    #[test]
736
    fn predicates_agree_with_flags() {
737
        let f = flags();
738
        assert_eq!(memory_enabled(), f.memory);
739
        assert_eq!(cpu_enabled(), f.cpu);
740
        assert_eq!(cascade_enabled(), f.cascade);
741
        assert_eq!(heap_enabled(), f.heap);
742
        assert_eq!(jsonl_enabled(), f.jsonl);
743
        assert_eq!(detail_enabled(), f.detail);
744
    }
745

            
746
    #[test]
747
    fn predicates_are_idempotent() {
748
        for _ in 0..100 {
749
            assert_eq!(memory_enabled(), memory_enabled());
750
            assert_eq!(cpu_enabled(), cpu_enabled());
751
            assert_eq!(cascade_enabled(), cascade_enabled());
752
            assert_eq!(heap_enabled(), heap_enabled());
753
            assert_eq!(jsonl_enabled(), jsonl_enabled());
754
            assert_eq!(detail_enabled(), detail_enabled());
755
        }
756
    }
757

            
758
    #[test]
759
    fn out_path_does_not_panic_and_is_cached() {
760
        let first = out_path();
761
        for _ in 0..100 {
762
            assert_eq!(out_path(), first, "out_path() is not stable across calls");
763
        }
764
        // If a path *is* configured it must be a real (possibly empty) &'static
765
        // str handed back from the same cached allocation every time.
766
        if let Some(p) = first {
767
            assert_eq!(out_path().map(str::as_ptr), Some(p.as_ptr()));
768
        }
769
    }
770

            
771
    /// `no_std` builds have no environment: profiling must be hard-off.
772
    #[cfg(not(feature = "std"))]
773
    #[test]
774
    fn nostd_profiling_is_always_off() {
775
        assert_eq!(flags(), ProfileFlags::default());
776
        assert_eq!(out_path(), None);
777
        assert!(!memory_enabled());
778
        assert!(!cpu_enabled());
779
        assert!(!cascade_enabled());
780
        assert!(!heap_enabled());
781
        assert!(!jsonl_enabled());
782
        assert!(!detail_enabled());
783
    }
784

            
785
    /// Under `std`, `flags()` must agree with whatever `AZ_PROFILE` said at
786
    /// first access — and, crucially, must keep agreeing even if the env var is
787
    /// later changed by some other part of the process (it is cached forever).
788
    #[cfg(feature = "std")]
789
    #[test]
790
    fn std_flags_match_env_or_default_and_never_change() {
791
        let observed = flags();
792
        let expected_now = std::env::var("AZ_PROFILE")
793
            .map(|v| ProfileFlags::parse(&v))
794
            .unwrap_or_default();
795
        // The cache is filled on first access; within one test binary the env
796
        // var is not mutated, so these must agree.
797
        assert_eq!(observed, expected_now);
798
        assert_eq!(flags(), observed);
799
    }
800
}