Skip to main content

varar_core/
result.rs

1//! Immutable run-result records — port of `result.ts` / `Result.java`. The
2//! persisted `.varar/<oath>.json` file is a serialized [`OathResults`].
3
4use crate::json_escape;
5use std::fmt::Write;
6
7/// One mismatched CELL as a source-offset range plus the runtime value.
8/// `from`/`to` are absolute UTF-16 source offsets; `to` is exclusive.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct CellFailure {
11    pub from: usize,
12    pub to: usize,
13    pub actual: String,
14}
15
16impl CellFailure {
17    pub fn new(from: usize, to: usize, actual: impl Into<String>) -> CellFailure {
18        CellFailure {
19            from,
20            to,
21            actual: actual.into(),
22        }
23    }
24}
25
26/// An example's run outcome.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum Status {
29    Passed,
30    Failed,
31}
32
33/// Where a failure points in the source: an offset range, `to` exclusive. The
34/// failing step's match span, or the first mismatched cell's span (the
35/// [`crate::failure_anchor`] rule). This is what lets a renderer underline the
36/// step that failed rather than the whole line it sits on.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct AnchorRange {
39    pub from: usize,
40    pub to: usize,
41}
42
43/// The failure payload of a failed [`ExampleResult`]. `cells` is `None`
44/// when not applicable. `line` may be a caller-supplied fallback (`-1`).
45/// `anchor` is `None` when the failure carries no location for this oath —
46/// optional for the same reason `cells` is, and a renderer then falls back to
47/// `line`.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ExampleFailure {
50    pub line: i64,
51    pub message: String,
52    pub stack: String,
53    pub cells: Option<Vec<CellFailure>>,
54    pub anchor: Option<AnchorRange>,
55    /// The document `line`, `cells` and `anchor` are offsets INTO. `None` — the
56    /// overwhelming majority — means the oath itself. Set only when the failing
57    /// step was spliced in from another oath by a reference block (ADR 0016):
58    /// its spans belong to that document, and a renderer that placed them in
59    /// this one would underline whatever text sat at those offsets.
60    pub doc_path: Option<String>,
61}
62
63/// An oath other than this one that contributed steps to the run, with its
64/// source hash as run (ADR 0016).
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct ReferencedDocument {
67    pub path: String,
68    pub source_hash: String,
69}
70
71/// The run result for one BDD example.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct ExampleResult {
74    pub name: String,
75    pub status: Status,
76    pub lines: Vec<usize>,
77    pub failure: Option<ExampleFailure>,
78}
79
80/// The persisted run result for one oath file.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct OathResults {
83    pub version: u32,
84    pub oath_path: String,
85    pub source_hash: String,
86    /// Every OTHER document this run's steps came from — the oaths a reference
87    /// block pulled steps in from (ADR 0016), with their hashes as run. Empty
88    /// when no step was spliced in, which is the common case.
89    pub documents: Vec<ReferencedDocument>,
90    pub examples: Vec<ExampleResult>,
91}
92
93/// Projects [`OathResults`] onto the JSON of `.varar/<oath_path>.json` (ADR 0014):
94/// the TypeScript field names, declaration order, 2-space indent, optional
95/// members absent rather than null. No trailing newline — the writer adds it.
96///
97/// Written by hand because the reference implementation writes the payload in
98/// declaration order, and this file is read by humans diffing it as much as by
99/// the language server.
100pub fn to_wire_json(results: &OathResults) -> String {
101    let mut out = String::new();
102    out.push_str("{\n");
103    field(&mut out, 1, "version", &results.version.to_string(), true);
104    string_field(&mut out, 1, "oathPath", &results.oath_path, true);
105    string_field(&mut out, 1, "sourceHash", &results.source_hash, true);
106    if !results.documents.is_empty() {
107        indent(&mut out, 1);
108        out.push_str("\"documents\": [\n");
109        for (i, doc) in results.documents.iter().enumerate() {
110            indent(&mut out, 2);
111            out.push_str("{\n");
112            string_field(&mut out, 3, "path", &doc.path, true);
113            string_field(&mut out, 3, "sourceHash", &doc.source_hash, false);
114            out.push('\n');
115            indent(&mut out, 2);
116            out.push('}');
117            if i + 1 < results.documents.len() {
118                out.push(',');
119            }
120            out.push('\n');
121        }
122        indent(&mut out, 1);
123        out.push_str("],\n");
124    }
125    indent(&mut out, 1);
126    out.push_str("\"examples\": ");
127    write_examples(&mut out, &results.examples, 1);
128    out.push('\n');
129    out.push('}');
130    out
131}
132
133fn write_examples(out: &mut String, examples: &[ExampleResult], depth: usize) {
134    if examples.is_empty() {
135        out.push_str("[]");
136        return;
137    }
138    out.push_str("[\n");
139    for (i, example) in examples.iter().enumerate() {
140        indent(out, depth + 1);
141        out.push_str("{\n");
142        string_field(out, depth + 2, "name", &example.name, true);
143        let status = match example.status {
144            Status::Passed => "passed",
145            Status::Failed => "failed",
146        };
147        string_field(out, depth + 2, "status", status, true);
148        indent(out, depth + 2);
149        out.push_str("\"lines\": ");
150        write_ints(out, &example.lines, depth + 2);
151        match &example.failure {
152            Some(failure) => {
153                out.push_str(",\n");
154                indent(out, depth + 2);
155                out.push_str("\"failure\": ");
156                write_failure(out, failure, depth + 2);
157                out.push('\n');
158            }
159            None => out.push('\n'),
160        }
161        indent(out, depth + 1);
162        out.push('}');
163        if i + 1 < examples.len() {
164            out.push(',');
165        }
166        out.push('\n');
167    }
168    indent(out, depth);
169    out.push(']');
170}
171
172fn write_failure(out: &mut String, failure: &ExampleFailure, depth: usize) {
173    out.push_str("{\n");
174    field(out, depth + 1, "line", &failure.line.to_string(), true);
175    string_field(out, depth + 1, "message", &failure.message, true);
176    let has_more =
177        failure.cells.is_some() || failure.anchor.is_some() || failure.doc_path.is_some();
178    string_field(out, depth + 1, "stack", &failure.stack, has_more);
179    if let Some(cells) = &failure.cells {
180        indent(out, depth + 1);
181        out.push_str("\"cells\": [\n");
182        for (i, cell) in cells.iter().enumerate() {
183            indent(out, depth + 2);
184            out.push_str("{\n");
185            field(out, depth + 3, "from", &cell.from.to_string(), true);
186            field(out, depth + 3, "to", &cell.to.to_string(), true);
187            string_field(out, depth + 3, "actual", &cell.actual, false);
188            indent(out, depth + 2);
189            out.push('}');
190            if i + 1 < cells.len() {
191                out.push(',');
192            }
193            out.push('\n');
194        }
195        indent(out, depth + 1);
196        out.push(']');
197        out.push_str(if failure.anchor.is_some() || failure.doc_path.is_some() {
198            ",\n"
199        } else {
200            "\n"
201        });
202    }
203    if let Some(anchor) = &failure.anchor {
204        indent(out, depth + 1);
205        out.push_str("\"anchor\": {\n");
206        field(out, depth + 2, "from", &anchor.from.to_string(), true);
207        field(out, depth + 2, "to", &anchor.to.to_string(), false);
208        indent(out, depth + 1);
209        out.push('}');
210        out.push_str(if failure.doc_path.is_some() {
211            ",\n"
212        } else {
213            "\n"
214        });
215    }
216    if let Some(doc_path) = &failure.doc_path {
217        string_field(out, depth + 1, "docPath", doc_path, false);
218        out.push('\n');
219    }
220    indent(out, depth);
221    out.push('}');
222}
223
224fn write_ints(out: &mut String, values: &[usize], depth: usize) {
225    if values.is_empty() {
226        out.push_str("[]");
227        return;
228    }
229    out.push_str("[\n");
230    for (i, value) in values.iter().enumerate() {
231        indent(out, depth + 1);
232        let _ = write!(out, "{value}");
233        if i + 1 < values.len() {
234            out.push(',');
235        }
236        out.push('\n');
237    }
238    indent(out, depth);
239    out.push(']');
240}
241
242fn field(out: &mut String, depth: usize, key: &str, raw_value: &str, comma: bool) {
243    indent(out, depth);
244    let _ = write!(out, "\"{key}\": {raw_value}");
245    out.push_str(if comma { ",\n" } else { "\n" });
246}
247
248fn string_field(out: &mut String, depth: usize, key: &str, value: &str, comma: bool) {
249    indent(out, depth);
250    let _ = write!(out, "\"{key}\": ");
251    json_escape::write_string(out, value);
252    out.push_str(if comma { ",\n" } else { "\n" });
253}
254
255fn indent(out: &mut String, depth: usize) {
256    for _ in 0..depth {
257        out.push_str("  ");
258    }
259}