Skip to main content

browser_commander/traces/
links.rs

1//! A Links Notation view of a trace bundle (issue #94).
2//!
3//! The same lines as `js/src/traces/links.js`: one link per line, written while
4//! a trace records and again, identically, from a finished bundle. The JSON
5//! bundle stays authoritative; this is an adapter over what it holds.
6
7use std::fs::{self, File};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use super::bundle::{open_private, resolve_path, TraceProblem};
12use super::jsonfmt::{Json, JsonObject};
13use super::raw_reader::{diff_controls, index_of, read_raw_trace, RawTrace};
14use super::reader::TraceError;
15use super::schema::{TraceEvent, TraceOutcome, TRACE_FORMAT};
16
17/// Version of this representation, independent of the bundle's schema.
18pub const TRACE_LINKS_VERSION: u64 = 1;
19
20/// File name used when an output path names a directory.
21pub const TRACE_LINKS_FILE: &str = "trace.lino";
22
23/// Sections a caller can include, in the order they are written.
24pub const TRACE_LINKS_SECTIONS: [&str; 4] = ["trace", "timeline", "checkpoints", "control-diffs"];
25
26/// Where and what to write as Links Notation alongside a recording.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct TraceLinksOptions {
29    /// A file, or a directory that receives `trace.lino`.
30    pub output: PathBuf,
31    /// Sections to write; `None` writes all of [`TRACE_LINKS_SECTIONS`].
32    pub include: Option<Vec<String>>,
33}
34
35/// Why an export could not be produced.
36#[derive(Debug, thiserror::Error)]
37pub enum TraceExportError {
38    /// The bundle could not be read.
39    #[error(transparent)]
40    Read(#[from] TraceError),
41    /// The options name something that does not exist.
42    #[error("{0}")]
43    Invalid(String),
44    /// The export could not be written.
45    #[error("cannot write {path}: {source}")]
46    Io {
47        /// File that could not be written.
48        path: PathBuf,
49        /// What the filesystem reported.
50        source: std::io::Error,
51    },
52}
53
54/// One link: an id and, optionally, values that are links themselves.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub(crate) struct Link {
57    id: String,
58    values: Vec<Link>,
59}
60
61impl Link {
62    fn new(id: impl Into<String>, values: Vec<Link>) -> Self {
63        Self {
64            id: id.into(),
65            values,
66        }
67    }
68
69    /// `Link.format(false)` from `links-notation`.
70    fn format(&self) -> String {
71        let id = escape_reference(&self.id);
72        if self.values.is_empty() {
73            return format!("({id})");
74        }
75        let values = self
76            .values
77            .iter()
78            .map(|value| {
79                if value.values.is_empty() {
80                    escape_reference(&value.id)
81                } else {
82                    value.format()
83                }
84            })
85            .collect::<Vec<_>>()
86            .join(" ");
87        format!("({id}: {values})")
88    }
89}
90
91/// `Link.escapeReference` from `links-notation`.
92fn escape_reference(reference: &str) -> String {
93    if reference.is_empty() {
94        return "\"\"".to_string();
95    }
96    let single = reference.contains('\'');
97    let double = reference.contains('"');
98    if single && double {
99        return format!("'{}'", reference.replace('\'', "\\'"));
100    }
101    if double {
102        return format!("'{reference}'");
103    }
104    if single {
105        return format!("\"{reference}\"");
106    }
107    let needs_quoting = reference.starts_with('#')
108        || reference
109            .chars()
110            .any(|c| matches!(c, ':' | '(' | ')' | ' ' | '\t' | '\n' | '\r'));
111    if needs_quoting {
112        format!("'{reference}'")
113    } else {
114        reference.to_string()
115    }
116}
117
118/// Make one value safe to write as a link, reversibly.
119pub fn encode_link_text(value: &Json) -> String {
120    let text = match value {
121        Json::String(text) => text.clone(),
122        other => other.to_compact(),
123    };
124    let escaped = text
125        .replace('\\', "\\\\")
126        .replace('\n', "\\n")
127        .replace('\r', "\\r")
128        .replace('\t', "\\t");
129    if escaped.contains('\'') && escaped.contains('"') {
130        escaped.replace('"', "\\u0022")
131    } else {
132        escaped
133    }
134}
135
136/// Read back what [`encode_link_text`] wrote.
137pub fn decode_link_text(text: &str) -> String {
138    let mut decoded = String::with_capacity(text.len());
139    let mut rest = text;
140    while let Some(at) = rest.find('\\') {
141        decoded.push_str(&rest[..at]);
142        let tail = &rest[at + 1..];
143        let (replacement, used) = if tail.starts_with('\\') {
144            ("\\", 1)
145        } else if tail.starts_with('n') {
146            ("\n", 1)
147        } else if tail.starts_with('r') {
148            ("\r", 1)
149        } else if tail.starts_with('t') {
150            ("\t", 1)
151        } else if tail.starts_with("u0022") {
152            ("\"", 5)
153        } else {
154            ("\\", 0)
155        };
156        decoded.push_str(replacement);
157        rest = &tail[used..];
158    }
159    decoded.push_str(rest);
160    decoded
161}
162
163fn leaf(value: &Json) -> Link {
164    Link::new(encode_link_text(value), Vec::new())
165}
166
167fn field(name: &str, value: Option<&Json>) -> Option<Link> {
168    match value {
169        None | Some(Json::Null) => None,
170        Some(value) => Some(Link::new(name, vec![leaf(value)])),
171    }
172}
173
174/// `a ?? b`: the first value that is neither missing nor `null`.
175fn coalesce<'a>(values: &[Option<&'a Json>]) -> Option<&'a Json> {
176    values
177        .iter()
178        .copied()
179        .flatten()
180        .find(|value| !value.is_null())
181}
182
183fn implied_actor(kind: Option<&str>) -> Option<&'static str> {
184    Some(match kind? {
185        TraceEvent::TRACE_START | TraceEvent::TRACE_STOP => "recorder",
186        TraceEvent::MUTATIONS | TraceEvent::DROPPED => "recorder",
187        TraceEvent::INTERACTION | TraceEvent::CHECKPOINT => "automation",
188        TraceEvent::NAVIGATION | TraceEvent::CONSOLE | TraceEvent::PAGE_ERROR => "browser",
189        TraceEvent::DIALOG | TraceEvent::REQUEST_FAILED | TraceEvent::DOWNLOAD => "browser",
190        _ => return None,
191    })
192}
193
194const HEAD_FIELDS: [&str; 13] = [
195    "sequence",
196    "at",
197    "monotonicMs",
198    "kind",
199    "traceId",
200    "browserContextId",
201    "pageId",
202    "navigationId",
203    "frameId",
204    "action",
205    "actor",
206    "target",
207    "outcome",
208];
209
210fn outcome_of(event: &JsonObject) -> &'static str {
211    let truthy = |name: &str| event.get(name).is_some_and(Json::truthy);
212    if event.get("kind").and_then(Json::as_str) == Some(TraceEvent::DROPPED) {
213        return "dropped";
214    }
215    if event.get("ok") == Some(&Json::Bool(false)) || truthy("error") || truthy("failure") {
216        return "failed";
217    }
218    if event.get("truncated") == Some(&Json::Bool(true)) {
219        return "partial";
220    }
221    if event.get("ok") == Some(&Json::Bool(true)) {
222        return "ok";
223    }
224    "recorded"
225}
226
227/// One timeline event as one link.
228pub(crate) fn timeline_link(event: &JsonObject) -> Link {
229    let kind = event.get("kind").and_then(Json::as_str);
230    let implied = implied_actor(kind).map(Json::from);
231    let outcome = Json::from(outcome_of(event));
232    let mut values: Vec<Option<Link>> = vec![
233        field("sequence", event.get("sequence")),
234        field("at", event.get("at")),
235        field("monotonicMs", event.get("monotonicMs")),
236        field("kind", event.get("kind")),
237        field("trace", event.get("traceId")),
238        field("context", event.get("browserContextId")),
239        field("page", event.get("pageId")),
240        field("navigation", event.get("navigationId")),
241        field("frame", event.get("frameId")),
242        field("actor", coalesce(&[event.get("actor"), implied.as_ref()])),
243        field(
244            "action",
245            coalesce(&[event.get("action"), event.get("phase")]),
246        ),
247        field(
248            "target",
249            coalesce(&[event.get("target"), event.get("url"), event.get("member")]),
250        ),
251        field("outcome", Some(&outcome)),
252    ];
253    for (name, value) in event.iter() {
254        if HEAD_FIELDS.contains(&name.as_str()) || value.is_null() || name == "members" {
255            continue;
256        }
257        values.push(field(name, Some(value)));
258    }
259    Link::new("timeline", values.into_iter().flatten().collect())
260}
261
262/// One checkpoint as one link that points at its members.
263pub(crate) fn checkpoint_link(event: &JsonObject) -> Link {
264    let members = event.get("members").filter(|members| !members.is_null());
265    let member = |name: &str| members.and_then(|members| members.get(name));
266    let outcome = Json::from(outcome_of(event));
267    let values = [
268        field("index", event.get("index")),
269        field("sequence", event.get("sequence")),
270        field("at", event.get("at")),
271        field("name", event.get("name")),
272        field("actor", event.get("actor")),
273        field("reason", event.get("reason")),
274        field("page", event.get("pageId")),
275        field("navigation", event.get("navigationId")),
276        field("url", event.get("url")),
277        field("outcome", Some(&outcome)),
278        field("html", member("html")),
279        field("state", member("state")),
280        field("screenshot", member("screenshot")),
281    ];
282    Link::new("checkpoint", values.into_iter().flatten().collect())
283}
284
285fn control_diff_link(
286    change: &JsonObject,
287    checkpoint: &Json,
288    previous: &Json,
289    actor: Option<&Json>,
290) -> Link {
291    let values = [
292        field("checkpoint", Some(checkpoint)),
293        field("previous", Some(previous)),
294        field("path", change.get("path")),
295        field("change", change.get("change")),
296        field("before", change.get("before")),
297        field("after", change.get("after")),
298        field("actor", actor),
299    ];
300    Link::new("control-diff", values.into_iter().flatten().collect())
301}
302
303/// What the opening link says about a trace.
304#[derive(Debug, Clone, Default)]
305pub(crate) struct LinksHeader {
306    pub bundle: String,
307    pub schema_version: Option<Json>,
308    pub mode: Option<Json>,
309    pub engine: Option<Json>,
310    pub started_at: Option<Json>,
311    pub commander_version: Option<Json>,
312}
313
314fn header_link(about: &LinksHeader) -> Link {
315    let values = [
316        field("format", Some(&Json::from(TRACE_FORMAT))),
317        field("links", Some(&Json::from(TRACE_LINKS_VERSION))),
318        field("schema", about.schema_version.as_ref()),
319        field("bundle", Some(&Json::from(about.bundle.as_str()))),
320        field("mode", about.mode.as_ref()),
321        field("engine", about.engine.as_ref()),
322        field("started", about.started_at.as_ref()),
323        field("commander", about.commander_version.as_ref()),
324    ];
325    Link::new("trace", values.into_iter().flatten().collect())
326}
327
328fn result_link(manifest: &JsonObject, truncated: bool) -> Link {
329    let counts = manifest.get("counts");
330    let count = |name: &str| counts.and_then(|counts| counts.get(name));
331    let replay: Vec<Link> = manifest
332        .get("replay")
333        .and_then(Json::as_object)
334        .map(|replay| {
335            replay
336                .iter()
337                .filter(|(_, supported)| supported.truthy())
338                .map(|(name, _)| leaf(&Json::from(name.as_str())))
339                .collect()
340        })
341        .unwrap_or_default();
342    let mut values: Vec<Link> = [
343        field("outcome", manifest.get("outcome")),
344        field("stopped", manifest.get("stoppedAt")),
345        field("events", count("events")),
346        field("checkpoints", count("checkpoints")),
347        field("mutationBatches", count("mutationBatches")),
348        field("dropped", manifest.get("dropped")),
349        field("truncated", Some(&Json::Bool(truncated))),
350    ]
351    .into_iter()
352    .flatten()
353    .collect();
354    if !replay.is_empty() {
355        values.push(Link::new("replay", replay));
356    }
357    Link::new("result", values)
358}
359
360/// One link per line, newline terminated.
361pub(crate) fn format_trace_links(links: &[Link]) -> String {
362    links
363        .iter()
364        .map(|link| format!("{}\n", link.format()))
365        .collect()
366}
367
368/// Check `include` and return the sections it names.
369pub(crate) fn chosen_sections(include: Option<&[String]>) -> Result<Vec<String>, String> {
370    let Some(include) = include else {
371        return Ok(TRACE_LINKS_SECTIONS
372            .iter()
373            .map(|name| name.to_string())
374            .collect());
375    };
376    for name in include {
377        if !TRACE_LINKS_SECTIONS.contains(&name.as_str()) {
378            return Err(format!(
379                "unknown trace links section \"{name}\"; expected one of {}",
380                TRACE_LINKS_SECTIONS.join(", ")
381            ));
382        }
383    }
384    Ok(include.to_vec())
385}
386
387fn has(sections: &[String], name: &str) -> bool {
388    sections.iter().any(|section| section == name)
389}
390
391fn links_for_event(event: &JsonObject, sections: &[String]) -> Vec<Link> {
392    let mut links = Vec::new();
393    if has(sections, "timeline") {
394        links.push(timeline_link(event));
395    }
396    let checkpoint = event.get("kind").and_then(Json::as_str) == Some(TraceEvent::CHECKPOINT);
397    if checkpoint && has(sections, "checkpoints") {
398        links.push(checkpoint_link(event));
399    }
400    links
401}
402
403fn control_diff_links(opened: &RawTrace) -> Result<Vec<Link>, TraceError> {
404    let mut links = Vec::new();
405    let mut previous: Option<(Json, Json)> = None;
406    for checkpoint in &opened.checkpoints {
407        let state = match index_of(checkpoint) {
408            Some(index) => opened.state(index)?.filter(Json::truthy),
409            None => None,
410        };
411        let index = checkpoint.get("index").cloned().unwrap_or(Json::Null);
412        if let (Some((before_index, before)), Some(state)) = (&previous, &state) {
413            for change in diff_controls(Some(before), Some(state)) {
414                links.push(control_diff_link(
415                    &change,
416                    &index,
417                    before_index,
418                    checkpoint.get("actor"),
419                ));
420            }
421        }
422        if let Some(state) = state {
423            previous = Some((index, state));
424        }
425    }
426    Ok(links)
427}
428
429fn file_name(path: &Path) -> String {
430    path.file_name()
431        .map(|name| name.to_string_lossy().into_owned())
432        .unwrap_or_default()
433}
434
435fn build_trace_links(opened: &RawTrace, sections: &[String]) -> Result<Vec<Link>, TraceError> {
436    let mut links = Vec::new();
437    let manifest = &opened.manifest;
438    if has(sections, "trace") {
439        links.push(header_link(&LinksHeader {
440            bundle: file_name(&opened.path),
441            schema_version: manifest.get("schemaVersion").cloned(),
442            mode: manifest.get("mode").cloned(),
443            engine: manifest.get("engine").cloned(),
444            started_at: manifest.get("startedAt").cloned(),
445            commander_version: manifest.get("commanderVersion").cloned(),
446        }));
447    }
448    for event in &opened.events {
449        if let Some(event) = event.as_object() {
450            links.extend(links_for_event(event, sections));
451        }
452    }
453    if has(sections, "control-diffs") {
454        links.extend(control_diff_links(opened)?);
455    }
456    if has(sections, "trace") {
457        links.push(result_link(manifest, opened.truncated));
458    }
459    Ok(links)
460}
461
462/// A finished (or interrupted) bundle as Links Notation text.
463///
464/// # Errors
465///
466/// Fails when the bundle cannot be read or `include` names an unknown section.
467pub fn trace_links(
468    bundle: impl AsRef<Path>,
469    include: Option<&[String]>,
470) -> Result<String, TraceExportError> {
471    let sections = chosen_sections(include).map_err(TraceExportError::Invalid)?;
472    let opened = read_raw_trace(bundle.as_ref())?;
473    Ok(format_trace_links(&build_trace_links(&opened, &sections)?))
474}
475
476fn resolve_output(output: &Path) -> Result<PathBuf, String> {
477    if output.as_os_str().is_empty() {
478        return Err("trace links output must be a path".to_string());
479    }
480    let resolved = resolve_path(output);
481    if resolved.is_dir() {
482        return Ok(resolved.join(TRACE_LINKS_FILE));
483    }
484    Ok(resolved)
485}
486
487fn create_parent(file: &Path) -> std::io::Result<()> {
488    match file.parent() {
489        Some(parent) => fs::create_dir_all(parent),
490        None => Ok(()),
491    }
492}
493
494/// Write a bundle as Links Notation; the file that was written.
495///
496/// # Errors
497///
498/// Fails when the bundle cannot be read, `include` names an unknown section or
499/// the file cannot be written.
500pub fn write_trace_links(
501    bundle: impl AsRef<Path>,
502    output: impl AsRef<Path>,
503    include: Option<&[String]>,
504) -> Result<PathBuf, TraceExportError> {
505    let file = resolve_output(output.as_ref()).map_err(TraceExportError::Invalid)?;
506    let text = trace_links(bundle, include)?;
507    let io = |source| TraceExportError::Io {
508        path: file.clone(),
509        source,
510    };
511    create_parent(&file).map_err(io)?;
512    open_private(&file, false)
513        .and_then(|mut handle| handle.write_all(text.as_bytes()))
514        .map_err(io)?;
515    Ok(file)
516}
517
518/// The export written while a trace records.
519pub(crate) struct LinksSink {
520    pub path: PathBuf,
521    pub problems: Vec<TraceProblem>,
522    sections: Vec<String>,
523    file: Option<File>,
524}
525
526impl LinksSink {
527    /// Open the export and write its header.
528    pub fn open(options: &TraceLinksOptions, about: LinksHeader) -> Result<Self, String> {
529        let path = resolve_output(&options.output)?;
530        let sections = chosen_sections(options.include.as_deref())?;
531        create_parent(&path).map_err(|error| error.to_string())?;
532        let file = open_private(&path, false).map_err(|error| error.to_string())?;
533        let mut sink = Self {
534            path,
535            problems: Vec::new(),
536            sections,
537            file: Some(file),
538        };
539        if has(&sink.sections, "trace") {
540            sink.append(&[header_link(&about)]);
541        }
542        Ok(sink)
543    }
544
545    fn append(&mut self, links: &[Link]) {
546        if links.is_empty() {
547            return;
548        }
549        let Some(file) = self.file.as_mut() else {
550            return;
551        };
552        if let Err(error) = file.write_all(format_trace_links(links).as_bytes()) {
553            self.problems.push(TraceProblem {
554                reason: None,
555                member: Some(self.path.to_string_lossy().into_owned()),
556                detail: Some(error.to_string()),
557            });
558        }
559    }
560
561    /// Write the links of one event as it is recorded.
562    pub fn event(&mut self, event: &JsonObject) {
563        let links = links_for_event(event, &self.sections);
564        self.append(&links);
565    }
566
567    /// Append the control diffs and the result, then close the file.
568    pub fn close(&mut self, manifest: &JsonObject, bundle_path: Option<&Path>) {
569        if self.file.is_none() {
570            return;
571        }
572        let mut links = Vec::new();
573        if let (true, Some(bundle_path)) = (has(&self.sections, "control-diffs"), bundle_path) {
574            match read_raw_trace(bundle_path).and_then(|opened| control_diff_links(&opened)) {
575                Ok(diffs) => links.extend(diffs),
576                Err(error) => self.problems.push(TraceProblem {
577                    reason: None,
578                    member: Some(self.path.to_string_lossy().into_owned()),
579                    detail: Some(error.to_string()),
580                }),
581            }
582        }
583        if has(&self.sections, "trace") {
584            let complete =
585                manifest.get("outcome").and_then(Json::as_str) == Some(TraceOutcome::COMPLETE);
586            links.push(result_link(manifest, !complete));
587        }
588        self.append(&links);
589        if let Some(mut file) = self.file.take() {
590            let _ = file.flush();
591        }
592    }
593
594    /// Remove the export of a discarded run.
595    pub fn discard(&mut self) {
596        self.file = None;
597        let _ = fs::remove_file(&self.path);
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn references_are_quoted_like_links_notation() {
607        assert_eq!(escape_reference(""), "\"\"");
608        assert_eq!(escape_reference("plain"), "plain");
609        assert_eq!(escape_reference("a b"), "'a b'");
610        assert_eq!(escape_reference("#go"), "'#go'");
611        assert_eq!(escape_reference("it's"), "\"it's\"");
612        assert_eq!(escape_reference("say \"hi\""), "'say \"hi\"'");
613        assert_eq!(escape_reference("'\""), "'\\'\"'");
614    }
615
616    #[test]
617    fn link_text_round_trips() {
618        let text = "a\\b\n\tc \"d\" 'e'";
619        let encoded = encode_link_text(&Json::from(text));
620        assert_eq!(encoded, "a\\\\b\\n\\tc \\u0022d\\u0022 'e'");
621        assert_eq!(decode_link_text(&encoded), text);
622        assert_eq!(encode_link_text(&Json::from(2.0)), "2");
623    }
624
625    #[test]
626    fn unknown_sections_are_rejected() {
627        let error = chosen_sections(Some(&["timeline".into(), "nope".into()])).unwrap_err();
628        assert_eq!(
629            error,
630            "unknown trace links section \"nope\"; expected one of trace, timeline, checkpoints, control-diffs"
631        );
632    }
633}