buildline 0.3.0

One timeline for your whole build, no matter how many build systems it's made of.
Documentation
use super::Adapter;
use crate::span::{Category, Span, Status};
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;

/// Reads a raw [Chrome Trace Event
/// Format](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU)
/// file: the same format `chrome_trace::to_chrome_trace` writes for
/// Perfetto, and one several real build tools already emit natively without
/// any help from buildline: `tsc --generateTrace <dir>` (TypeScript) writes
/// `trace.json` in exactly this shape, and Bazel's
/// `--generate_json_trace_profile` does too. Any tool that already speaks
/// this format merges into a session with zero bespoke parsing, at the cost
/// of the fidelity a hand-written adapter (see `webpack.rs`) gets from
/// understanding that one tool's specific quirks.
///
/// Accepts either shape the spec allows: a bare JSON array of events, or an
/// object with a `traceEvents` array (what `to_chrome_trace` itself emits,
/// and what most tools write). Only "Complete" (`ph: "X"`) events become
/// spans. Every other phase (metadata, counters, async/flow events, instant
/// events, and streamed "Begin"/"End" `ph: "B"`/`"E"` pairs) is silently
/// skipped for now: informative in Perfetto's own UI, but "B"/"E" pairing
/// (matching each begin to its end, correctly nested) is real added
/// complexity this first version doesn't take on. See [Known
/// limitations](../../README.md#known-limitations).
///
/// **Honest limits, same spirit as the other adapters:**
/// - The format carries no success/failure concept in its base spec, so
///   every span is `Status::Success`, same as ninja and webpack today.
/// - `track` comes from the input's own `process_name` metadata event
///   (`ph: "M"`), keyed by `pid`, when the source tool wrote one. Falls back
///   to `"generic"` for any pid that has none, so a trace with no metadata
///   at all still merges, just as a single undifferentiated track rather
///   than being rejected outright.
/// - `ts`/`dur` are already microseconds in this format (the one adapter
///   here that needs no unit conversion at all), taken as-is.
pub struct Generic;

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TraceInput {
    Wrapped {
        #[serde(rename = "traceEvents")]
        trace_events: Vec<RawEvent>,
    },
    Bare(Vec<RawEvent>),
}

#[derive(Debug, Deserialize, Clone)]
struct RawEvent {
    ph: String,
    #[serde(default)]
    name: String,
    #[serde(default)]
    cat: Option<String>,
    #[serde(default)]
    ts: f64,
    #[serde(default)]
    dur: Option<f64>,
    #[serde(default)]
    pid: i64,
    #[serde(default)]
    tid: i64,
    #[serde(default)]
    args: Option<Value>,
}

impl Adapter for Generic {
    fn name(&self) -> &'static str {
        "generic"
    }

    fn parse(&self, input: &[u8]) -> anyhow::Result<Vec<Span>> {
        let parsed: TraceInput = serde_json::from_slice(input)?;
        let events = match parsed {
            TraceInput::Wrapped { trace_events } => trace_events,
            TraceInput::Bare(events) => events,
        };

        let track_names = process_names(&events);

        let spans = events
            .iter()
            .filter(|e| e.ph == "X")
            .filter_map(|e| {
                let dur_us = e.dur?;
                Some(Span {
                    name: e.name.clone(),
                    category: classify(e),
                    status: Status::Success,
                    track: track_names
                        .get(&e.pid)
                        .cloned()
                        .unwrap_or_else(|| "generic".to_string()),
                    lane: e.tid.max(0) as u32,
                    start_us: e.ts.round() as i64,
                    dur_us: dur_us.round() as i64,
                    args: string_args(e.args.as_ref()),
                })
            })
            .collect();

        Ok(spans)
    }
}

/// Maps `pid` -> the name a `process_name` metadata event (`ph: "M"`) gave
/// it, e.g. `{"ph":"M","name":"process_name","pid":0,"args":{"name":"tsc"}}`.
/// Pids with no such event are simply absent from the map; callers fall back
/// to a default track name.
fn process_names(events: &[RawEvent]) -> BTreeMap<i64, String> {
    events
        .iter()
        .filter(|e| e.ph == "M" && e.name == "process_name")
        .filter_map(|e| {
            let name = e.args.as_ref()?.get("name")?.as_str()?.to_string();
            Some((e.pid, name))
        })
        .collect()
}

/// Prefers the event's own `cat` (Chrome trace's category field), falling
/// back to `name` when `cat` is absent, same escape hatch as every other
/// adapter: `Category::from` recognizes the shared vocabulary and folds
/// anything else into `Other(String)` rather than guessing.
fn classify(e: &RawEvent) -> Category {
    Category::from(e.cat.as_deref().unwrap_or(&e.name))
}

/// Flattens a Chrome-trace `args` object into `Span::args`' `BTreeMap<String,
/// String>` shape: string values are copied as-is, anything else (numbers,
/// nested objects, arrays) is rendered via its compact JSON form so it's
/// still visible in Perfetto's args panel rather than silently dropped.
fn string_args(args: Option<&Value>) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(Value::Object(map)) = args {
        for (k, v) in map {
            let s = match v {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            out.insert(k.clone(), s);
        }
    }
    out
}