///|
/// How the lines of a JSONL log become transcript entries. The peer names are
/// checked against the protocol after the model is loaded.
priv enum LogChoice {
/// `--log-preset raw --sender `: each JSON-RPC line comes from one peer.
RawLog(@rpc.PeerName)
/// `--log-preset dir-message`, the default: `{"dir", "message"}` records.
/// The `--direction` map, or `None` for request = the first peer and
/// response = the second.
DirMessageLog(Map[String, @rpc.PeerName]?)
/// `--log-mapping `: a mapping in the `LogMapping::from_json` form.
MappingFile(String)
}
///|
/// Reads --log-preset, --log-mapping, --sender and --direction. A mapping file
/// names its own senders, raw takes only --sender, and dir-message takes only
/// --direction.
fn log_choice(
preset : String?,
mapping : String?,
sender : String?,
directions : Array[String],
) -> LogChoice raise CliError {
fn peer(text : String, option : String) -> @rpc.PeerName raise CliError {
guard @rpc.PeerName::parse(text) is Some(peer) else {
raise CliError("expected a peer name after \{option}, found '\{text}'", 2)
}
peer
}
match (preset, mapping) {
(Some(_), Some(_)) =>
raise CliError("use only one of --log-preset and --log-mapping", 2)
(None, Some(file)) => {
if sender is Some(_) || !directions.is_empty() {
raise CliError(
"--log-mapping names the senders; do not use --sender or --direction with it",
2,
)
}
MappingFile(file)
}
(Some("raw"), None) => {
if !directions.is_empty() {
raise CliError("--direction requires --log-preset dir-message", 2)
}
guard sender is Some(name) else {
raise CliError("--log-preset raw requires --sender ", 2)
}
RawLog(peer(name, "--sender"))
}
(Some("dir-message") | None, None) => {
if sender is Some(_) {
raise CliError("--sender requires --log-preset raw", 2)
}
if directions.is_empty() {
return DirMessageLog(None)
}
let map : Map[String, @rpc.PeerName] = Map([])
for direction in directions {
guard direction.rev_find("=") is Some(at) &&
at > 0 &&
at < direction.length() - 1 else {
raise CliError(
"expected value=peer after --direction, found '\{direction}'",
2,
)
}
let name = direction[:at].to_owned()
if map.contains(name) {
raise CliError("--direction gives the value '\{name}' twice", 2)
}
map[name] = peer(direction[at + 1:].to_owned(), "--direction \{name}=")
}
DirMessageLog(Some(map))
}
(Some(other), None) =>
raise CliError(
"expected raw or dir-message after --log-preset, found '\{other}'",
2,
)
}
}
///|
/// Builds the log mapping of `choice`. Every peer it names must be a peer of
/// the protocol. A mapping that the library rejects is a usage error.
fn log_mapping(
choice : LogChoice,
protocol : @rpc.ProtocolDescription,
) -> @rpc.LogMapping raise CliError {
let peers = protocol.peers()
let names = peers.map(p => p.to_string()).join(", ")
fn known(peer : @rpc.PeerName, option : String) -> Unit raise CliError {
if !peers.contains(peer) {
raise CliError(
"\{option} names \{peer.to_string()}, which is not a peer of \{protocol.protocol().to_string()} (\{names})",
2,
)
}
}
fn dir_message(
directions : Map[String, @rpc.PeerName],
) -> @rpc.LogMapping raise CliError {
@rpc.LogMapping::dir_message(directions) catch {
@validation.ConfigurationError(message) =>
raise CliError("--direction: \{message}", 2)
}
}
match choice {
RawLog(sender) => {
known(sender, "--sender")
@rpc.LogMapping::raw(sender)
}
DirMessageLog(None) =>
dir_message({ "request": peers[0], "response": peers[1] })
DirMessageLog(Some(directions)) => {
for value, peer in directions {
known(peer, "--direction \{value}=")
}
dir_message(directions)
}
MappingFile(file) => {
let text = @fs.read_file_to_string(file) catch {
_ => raise CliError("could not read the --log-mapping file \{file}", 2)
}
@rpc.LogMapping::from_json(text, peers) catch {
@validation.ConfigurationError(message) =>
raise CliError("\{file}: \{message}", 2)
}
}
}
}
///|
/// Checks a JSONL log as it is read, and writes each finding when it is known:
/// one line per finding, then a summary line. With `expected`, the summary
/// also gives the expectation match, and the exit status follows it.
fn run_log_transcript(
opts : ValueOptions,
transcript : TranscriptOptions,
protocol : @rpc.ProtocolDescription,
choice : LogChoice,
expected : Array[@rpc.ExpectedFinding]?,
) -> Int32 raise {
let mapping = log_mapping(choice, protocol)
let reader = @rpc.LogReader::new(mapping) catch {
@validation.ConfigurationError(message) => raise CliError(message, 2)
}
// A log with one sender has no replies to correlate.
let checker = @rpc.TranscriptChecker::new(
protocol,
completeness=transcript.completeness.unwrap_or(@rpc.Partial),
correlation=!reader.single_sender(),
legacy_errors=transcript.legacy_errors.unwrap_or(@rpc.Opaque),
)
let outcome = stream_log(
() => chunk_source(transcript.file),
reader,
checker,
FindingStream::new(opts, finding_sink(opts), expected),
)
match outcome {
Checked(status) => status
Unreadable => transcript_input_problem(opts)
}
}
///|
/// How a streamed log ended.
priv enum LogOutcome {
/// The log was read and checked, with this exit status.
Checked(Int32)
/// The input did not open, or its first read failed before any byte
/// arrived. Nothing was written.
Unreadable
}
///|
/// Opens the log with `open`, feeds each chunk through the reader and the
/// checker, and writes the findings as they are known. An input that does
/// not open, or that fails on its first read, is `Unreadable`. A read error
/// part-way through still ends the log and writes the summary, which is then
/// not valid; the error is raised after it.
fn stream_log(
open : () -> InputSource raise,
reader : @rpc.LogReader,
checker : @rpc.TranscriptChecker,
output : FindingStream,
) -> LogOutcome raise {
let source = open() catch { _ => return Unreadable }
let chunks = Utf8Chunks::new()
fn check(text : String) -> Unit raise {
output.write_all(check_lines(reader.push_text(text), checker))
}
let failure = match read_chunks(source, bytes => check(chunks.push(bytes))) {
Ended => None
Unreadable(_) => return Unreadable
Failed(error) => Some(error)
}
check(chunks.end())
output.write_all(check_lines(reader.end(), checker))
let summary = checker.finish()
output.write_all(summary.findings)
let status = output.finish(
summary,
reader.counts(),
read_failed=failure is Some(_),
)
if failure is Some(error) {
raise error
}
Checked(status)
}
///|
/// Feeds the entries that a reader gave for some lines to the checker, and
/// gives the reader and checker findings in line order. A line has either an
/// entry or a reader finding, so the order does not depend on chunk sizes.
fn check_lines(
read : (Array[@rpc.TranscriptEntry], Array[@rpc.TranscriptFinding]),
checker : @rpc.TranscriptChecker,
) -> Array[@rpc.TranscriptFinding] {
let (entries, problems) = read
fn line(location : @rpc.SourceLocation?) -> Int32 {
location.map(at => at.line).unwrap_or(0)
}
let out = []
let mut problem = 0
for entry in entries {
while problem < problems.length() &&
line(problems[problem].location) < line(entry.location) {
out.push(problems[problem])
problem += 1
}
out.append(checker.feed(entry))
}
out.append(problems[problem:].to_owned())
out
}
///|
/// Writes streamed findings as they are known: text lines to stderr, or JSON
/// Lines to stdout. Each line is flushed. With an expect list, it keeps the
/// findings to match them at the end.
priv struct FindingStream {
opts : ValueOptions
sink : OutputSink
expected : Array[@rpc.ExpectedFinding]?
seen : Array[@rpc.TranscriptFinding]
mut errors : Bool
}
///|
/// The sink of streamed findings: stdout for JSON, otherwise stderr.
fn finding_sink(opts : ValueOptions) -> OutputSink raise {
if opts.diagnostics == JsonFindings {
output_sink(None)
} else {
stderr_sink()
}
}
///|
fn FindingStream::new(
opts : ValueOptions,
sink : OutputSink,
expected : Array[@rpc.ExpectedFinding]?,
) -> FindingStream {
{ opts, sink, expected, seen: [], errors: false, }
}
///|
fn FindingStream::line(self : FindingStream, text : String) -> Unit raise {
(self.sink.write)(@utf8.encode(text + "\n"))
(self.sink.flush)()
}
///|
fn FindingStream::write_all(
self : FindingStream,
findings : Array[@rpc.TranscriptFinding],
) -> Unit raise {
for finding in findings {
if finding.severity == @validation.Error {
self.errors = true
}
if self.expected is Some(_) {
self.seen.push(finding)
}
self.line(
if self.opts.diagnostics == JsonFindings {
@json.to_json(finding).stringify()
} else {
transcript_finding_text(self.opts.file, finding)
},
)
}
}
///|
/// Writes the summary line with the reader counts, and gives the exit status.
/// With an expect list, the summary has the match, and the status is 0 when
/// it matches. Otherwise the status is 0 when no finding is an error and no
/// limit stopped a check. A read error makes the summary not valid.
fn FindingStream::finish(
self : FindingStream,
summary : @rpc.TranscriptSummary,
counts : @rpc.LogCounts,
read_failed~ : Bool,
) -> Int32 raise {
let valid = summary.complete && !self.errors && !read_failed
let fields = summary_fields(valid, summary.complete, Some(summary), counts~)
let expectations = self.expected.map(expected => {
@rpc.match_expected(expected, self.seen)
})
if self.opts.diagnostics == JsonFindings {
let record = Map::from_array(fields)
if expectations is Some(result) {
record["expectations"] = expectations_json(result)
}
self.line(Json::object({ "summary": Json::object(record) }).stringify())
} else {
if expectations is Some(result) {
for line in expectation_lines(self.opts.file, result) {
self.line(line)
}
fields.push(("matched", Json::boolean(result.is_match())))
}
let text = fields.map(field => "\{field.0}:\{field.1.stringify()}")
self.line("\{self.opts.file}: summary: \{text.join(" ")}")
}
let passed = match expectations {
Some(result) => result.is_match() && !read_failed
None => valid
}
if passed {
0
} else {
1
}
}