ssb 0.1.1

Simple benchmarking for Rust, with hierarchical call tree, based on fastrace.
Documentation
use fastrace::Span;
use ssb::{Bench, BenchReport};

fn phase1(n: u64) -> u64 {
    let _s = Span::enter_with_local_parent("phase1");
    (0..n).fold(0u64, |acc, x| acc.wrapping_add(x))
}

fn phase2(x: u64) -> u64 {
    let _s = Span::enter_with_local_parent("phase2");
    x.wrapping_mul(31)
}

fn compute(n: u64) -> u64 {
    phase2(phase1(n))
}

#[test]
fn report_contains_expected_spans() {
    let report = Bench::new("compute")
        .no_auto_save()
        .iterations(20)
        .warmup(5)
        .run(|| compute(1_000));

    // Total span (the benchmark name) must be present
    assert!(report.spans.contains_key("compute"), "missing total span");
    assert!(report.spans.contains_key("phase1"), "missing phase1 span");
    assert!(report.spans.contains_key("phase2"), "missing phase2 span");

    // All spans should have been observed in every iteration.
    // The loop also respects min_run_seconds, so count may exceed the
    // requested iteration count — just verify the spans are consistent.
    let total_count = report.spans["compute"].count;
    assert!(
        total_count >= 20,
        "expected at least 20 iterations, got {total_count}"
    );
    assert_eq!(report.spans["phase1"].count, total_count);
    assert_eq!(report.spans["phase2"].count, total_count);

    // Sanity: total >= phase1 + phase2 (overhead exists but shouldn't be negative)
    let total_mean = report.spans["compute"].mean_ns;
    let phases_mean = report.spans["phase1"].mean_ns + report.spans["phase2"].mean_ns;
    assert!(
        total_mean >= phases_mean,
        "total ({total_mean:.0} ns) < sum of phases ({phases_mean:.0} ns)"
    );
}

#[test]
fn stats_are_ordered() {
    let report = Bench::new("compute")
        .no_auto_save()
        .iterations(50)
        .warmup(5)
        .run(|| compute(500));

    for (name, s) in &report.spans {
        assert!(s.min_ns <= s.mean_ns as u64, "{name}: min > mean");
        assert!(s.mean_ns <= s.max_ns as f64, "{name}: mean > max");
        assert!(s.p95_ns <= s.max_ns as f64, "{name}: p95 > max");
    }
}

#[test]
fn save_and_load_roundtrip() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("report.json");

    let original = Bench::new("compute")
        .no_auto_save()
        .iterations(10)
        .warmup(2)
        .run(|| compute(100));

    original.save(&path).unwrap();
    let loaded = BenchReport::load(&path).unwrap();

    assert_eq!(original.name, loaded.name);
    assert_eq!(original.iterations, loaded.iterations);
    assert_eq!(original.spans.len(), loaded.spans.len());
    for (k, orig_stats) in &original.spans {
        let loaded_stats = &loaded.spans[k];
        assert_eq!(orig_stats.count, loaded_stats.count);
        // f64 round-trips through JSON with enough precision for our purposes
        assert!((orig_stats.mean_ns - loaded_stats.mean_ns).abs() < 1.0);
    }
}

#[test]
fn comparison_smoke_test() {
    let baseline = Bench::new("compute")
        .no_auto_save()
        .iterations(10)
        .warmup(2)
        .run(|| compute(100));

    let current = Bench::new("compute")
        .no_auto_save()
        .iterations(10)
        .warmup(2)
        .run(|| compute(100));

    // Should not panic
    current.compare(&baseline).print();
}