use serde::Serialize;
use crate::engine::{CommandData, CommandResult, HumanRenderOptions};
use crate::error::WavepeekError;
use crate::schema_contract::SCHEMA_URL;
#[derive(Debug, Serialize)]
pub struct OutputEnvelope<T>
where
T: Serialize,
{
#[serde(rename = "$schema")]
pub schema: &'static str,
pub command: String,
pub data: T,
pub warnings: Vec<String>,
}
impl<T> OutputEnvelope<T>
where
T: Serialize,
{
pub fn with_warnings(command: impl Into<String>, data: T, warnings: Vec<String>) -> Self {
Self {
schema: SCHEMA_URL,
command: command.into(),
data,
warnings,
}
}
}
pub fn write(result: CommandResult) -> Result<(), WavepeekError> {
if !result.json {
let output = render_human(&result.data, result.human_options);
if !output.is_empty() {
write_stdout(output.as_str());
}
emit_human_warnings(&result.warnings);
return Ok(());
}
let json = render_json(result)?;
println!("{json}");
Ok(())
}
fn render_json(result: CommandResult) -> Result<String, WavepeekError> {
let envelope =
OutputEnvelope::with_warnings(result.command.as_str(), result.data, result.warnings);
serde_json::to_string(&envelope)
.map_err(|error| WavepeekError::Internal(format!("failed to serialize output: {error}")))
}
fn render_human(data: &CommandData, options: HumanRenderOptions) -> String {
match data {
CommandData::Schema(schema) => schema.clone(),
CommandData::Text(text) => text.clone(),
CommandData::Info(info) => {
let mut lines = Vec::new();
lines.push(format!("time_unit: {}", info.time_unit));
lines.push(format!("time_start: {}", info.time_start));
lines.push(format!("time_end: {}", info.time_end));
lines.join("\n")
}
CommandData::Scope(scopes) => {
if options.scope_tree {
render_scope_tree(scopes)
} else {
scopes
.iter()
.map(|entry| format!("{} {} kind={}", entry.depth, entry.path, entry.kind))
.collect::<Vec<_>>()
.join("\n")
}
}
CommandData::Signal(signals) => signals
.iter()
.map(|entry| match entry.width {
Some(width) => {
format!(
"{} kind={} width={width}",
signal_display_name(entry, options.signals_abs),
entry.kind
)
}
None => format!(
"{} kind={}",
signal_display_name(entry, options.signals_abs),
entry.kind
),
})
.collect::<Vec<_>>()
.join("\n"),
CommandData::Value(value_data) => {
let mut lines = Vec::with_capacity(value_data.signals.len() + 1);
lines.push(format!("@{}", value_data.time));
for signal in &value_data.signals {
let display = if options.signals_abs {
signal.path.as_str()
} else {
signal.display.as_str()
};
lines.push(format!("{display} {}", signal.value));
}
lines.join("\n")
}
CommandData::Change(snapshots) => snapshots
.iter()
.map(|snapshot| {
let mut parts = Vec::with_capacity(snapshot.signals.len() + 1);
parts.push(format!("@{}", snapshot.time));
for signal in &snapshot.signals {
let display = if options.signals_abs {
signal.path.as_str()
} else {
signal.display.as_str()
};
parts.push(format!("{display}={}", signal.value));
}
parts.join(" ")
})
.collect::<Vec<_>>()
.join("\n"),
CommandData::Property(rows) => rows
.iter()
.map(|row| format!("@{} {}", row.time, row.kind))
.collect::<Vec<_>>()
.join("\n"),
CommandData::DocsTopics(data) => data
.topics
.iter()
.map(|topic| format!("{} — {}", topic.id, topic.summary))
.collect::<Vec<_>>()
.join("\n"),
CommandData::DocsSearch(data) => data
.matches
.iter()
.map(|entry| format!("{} {}", entry.topic.id, entry.topic.summary))
.collect::<Vec<_>>()
.join("\n"),
}
}
fn render_scope_tree(scopes: &[crate::engine::scope::ScopeEntry]) -> String {
if scopes.is_empty() {
return String::new();
}
let mut lines = Vec::with_capacity(scopes.len());
let mut ancestor_last = Vec::new();
for (index, entry) in scopes.iter().enumerate() {
let label = entry.path.rsplit('.').next().unwrap_or(entry.path.as_str());
let scope_label = format!("{label} kind={}", entry.kind);
let is_last = scope_entry_is_last_sibling(scopes, index);
if entry.depth == 0 {
lines.push(scope_label);
} else {
let mut line = String::new();
for depth in 1..entry.depth {
let ancestor_is_last = ancestor_last.get(depth).copied().unwrap_or(true);
if ancestor_is_last {
line.push_str(" ");
} else {
line.push_str("│ ");
}
}
line.push_str(if is_last { "└── " } else { "├── " });
line.push_str(scope_label.as_str());
lines.push(line);
}
ancestor_last.truncate(entry.depth);
ancestor_last.push(is_last);
}
lines.join("\n")
}
fn scope_entry_is_last_sibling(scopes: &[crate::engine::scope::ScopeEntry], index: usize) -> bool {
let depth = scopes[index].depth;
for next in scopes.iter().skip(index + 1) {
if next.depth < depth {
return true;
}
if next.depth == depth {
return false;
}
}
true
}
fn signal_display_name(entry: &crate::engine::signal::SignalEntry, abs: bool) -> &str {
if abs {
entry.path.as_str()
} else {
entry.display.as_str()
}
}
fn emit_human_warnings(warnings: &[String]) {
for warning in warnings {
eprintln!("warning: {warning}");
}
}
fn write_stdout(output: &str) {
if output.ends_with('\n') {
print!("{output}");
} else {
println!("{output}");
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use crate::engine::{CommandData, CommandName, CommandResult, HumanRenderOptions};
use crate::schema_contract::SCHEMA_URL;
use super::{render_human, render_json};
#[test]
fn json_envelope_has_required_shape_for_info() {
let result = CommandResult {
command: CommandName::Info,
json: true,
human_options: HumanRenderOptions::default(),
data: CommandData::Info(crate::engine::info::InfoData {
time_unit: "1ns".to_string(),
time_start: "0ns".to_string(),
time_end: "10ns".to_string(),
}),
warnings: vec![],
};
let json = render_json(result).expect("json serialization should succeed");
let value: Value = serde_json::from_str(&json).expect("json should parse");
assert_eq!(value["$schema"], SCHEMA_URL);
assert!(value.get("schema_version").is_none());
assert_eq!(value["command"], "info");
assert!(value["data"].is_object());
assert!(value["warnings"].is_array());
}
#[test]
fn json_envelope_preserves_warnings_for_scope() {
let result = CommandResult {
command: CommandName::Scope,
json: true,
human_options: HumanRenderOptions::default(),
data: CommandData::Scope(vec![crate::engine::scope::ScopeEntry {
path: "top.cpu".to_string(),
depth: 1,
kind: "module".to_string(),
}]),
warnings: vec!["truncated to 1 entries".to_string()],
};
let json = render_json(result).expect("json serialization should succeed");
let value: Value = serde_json::from_str(&json).expect("json should parse");
assert_eq!(value["command"], "scope");
assert_eq!(value["warnings"][0], "truncated to 1 entries");
assert_eq!(value["data"][0]["path"], "top.cpu");
assert_eq!(value["data"][0]["depth"], 1);
assert_eq!(value["data"][0]["kind"], "module");
}
#[test]
fn docs_topics_json_envelope_uses_nested_topics_payload() {
let result = CommandResult {
command: CommandName::DocsTopics,
json: true,
human_options: HumanRenderOptions::default(),
data: CommandData::DocsTopics(crate::engine::DocsTopicsData {
topics: vec![crate::docs::TopicSummary {
id: "intro".to_string(),
title: "Introduction".to_string(),
summary: "Start here.".to_string(),
section: "intro".to_string(),
see_also: vec!["commands/help".to_string()],
}],
}),
warnings: vec![],
};
let json = render_json(result).expect("json serialization should succeed");
let value: Value = serde_json::from_str(&json).expect("json should parse");
assert_eq!(value["command"], "docs topics");
assert_eq!(value["data"]["topics"][0]["id"], "intro");
assert_eq!(value["data"]["topics"][0]["see_also"][0], "commands/help");
}
#[test]
fn property_rows_render_as_time_and_kind_lines() {
let rendered = render_human(
&CommandData::Property(vec![
crate::engine::property::PropertyCaptureRow {
time: "10ns".to_string(),
kind: crate::engine::property::PropertyResultKind::Assert,
},
crate::engine::property::PropertyCaptureRow {
time: "25ns".to_string(),
kind: crate::engine::property::PropertyResultKind::Deassert,
},
]),
HumanRenderOptions::default(),
);
assert_eq!(rendered, "@10ns assert\n@25ns deassert");
}
#[test]
fn scope_tree_render_matches_linux_tree_style() {
let rendered = render_human(
&CommandData::Scope(vec![
crate::engine::scope::ScopeEntry {
path: "top".to_string(),
depth: 0,
kind: "module".to_string(),
},
crate::engine::scope::ScopeEntry {
path: "top.cpu".to_string(),
depth: 1,
kind: "module".to_string(),
},
crate::engine::scope::ScopeEntry {
path: "top.cpu.alu".to_string(),
depth: 2,
kind: "function".to_string(),
},
crate::engine::scope::ScopeEntry {
path: "top.cpu.regs".to_string(),
depth: 2,
kind: "module".to_string(),
},
crate::engine::scope::ScopeEntry {
path: "top.mem".to_string(),
depth: 1,
kind: "module".to_string(),
},
]),
HumanRenderOptions {
scope_tree: true,
signals_abs: false,
},
);
assert_eq!(
rendered,
"top kind=module\n├── cpu kind=module\n│ ├── alu kind=function\n│ └── regs kind=module\n└── mem kind=module"
);
}
#[test]
fn value_human_render_is_deterministic_and_compact() {
let rendered = render_human(
&CommandData::Value(crate::engine::value::ValueData {
time: "10ns".to_string(),
signals: vec![
crate::engine::value::ValueSignalValue {
display: "clk".to_string(),
path: "top.clk".to_string(),
value: "1'h1".to_string(),
},
crate::engine::value::ValueSignalValue {
display: "data".to_string(),
path: "top.data".to_string(),
value: "8'h0f".to_string(),
},
],
}),
HumanRenderOptions::default(),
);
assert_eq!(rendered, "@10ns\nclk 1'h1\ndata 8'h0f");
}
#[test]
fn change_human_render_is_single_line_per_snapshot() {
let rendered = render_human(
&CommandData::Change(vec![crate::engine::change::ChangeSnapshot {
time: "5ns".to_string(),
signals: vec![
crate::engine::change::ChangeSignalValue {
display: "clk".to_string(),
path: "top.clk".to_string(),
value: "1'h1".to_string(),
},
crate::engine::change::ChangeSignalValue {
display: "data".to_string(),
path: "top.data".to_string(),
value: "8'h00".to_string(),
},
],
}]),
HumanRenderOptions::default(),
);
assert_eq!(rendered, "@5ns clk=1'h1 data=8'h00");
}
}