1use 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
17pub const TRACE_LINKS_VERSION: u64 = 1;
19
20pub const TRACE_LINKS_FILE: &str = "trace.lino";
22
23pub const TRACE_LINKS_SECTIONS: [&str; 4] = ["trace", "timeline", "checkpoints", "control-diffs"];
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct TraceLinksOptions {
29 pub output: PathBuf,
31 pub include: Option<Vec<String>>,
33}
34
35#[derive(Debug, thiserror::Error)]
37pub enum TraceExportError {
38 #[error(transparent)]
40 Read(#[from] TraceError),
41 #[error("{0}")]
43 Invalid(String),
44 #[error("cannot write {path}: {source}")]
46 Io {
47 path: PathBuf,
49 source: std::io::Error,
51 },
52}
53
54#[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 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
91fn 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
118pub 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
136pub 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
174fn 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
227pub(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
262pub(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#[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
360pub(crate) fn format_trace_links(links: &[Link]) -> String {
362 links
363 .iter()
364 .map(|link| format!("{}\n", link.format()))
365 .collect()
366}
367
368pub(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
462pub 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, §ions)?))
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
494pub 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
518pub(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 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 pub fn event(&mut self, event: &JsonObject) {
563 let links = links_for_event(event, &self.sections);
564 self.append(&links);
565 }
566
567 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 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}