pub mod change;
pub mod docs;
pub mod info;
pub mod limits;
pub mod property;
pub mod schema;
pub mod scope;
pub mod signal;
pub mod skill;
pub mod value;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
use crate::engine::{self, Command as EngineCommand};
use crate::error::WavepeekError;
use crate::output;
#[derive(Debug, Parser)]
#[command(
name = "wavepeek",
disable_version_flag = true,
about = "wavepeek is a machine-friendly command-line tool for VCD/FST waveform inspection.\nSee more with '--help'",
long_about = r#"wavepeek is a machine-friendly command-line tool for VCD/FST waveform inspection.
See more with '--help'
General conventions:
- Waveform-inspection commands require `--waves <FILE>`.
- Output is bounded by default (e.g. with `--max` or similar) and recursive traversals are depth-bounded.
- Default output is human-readable for waveform commands; `--json` enables machine-readable output and its contract is defined by `wavepeek schema`.
- Time values require explicit units (`zs`, `as`, `fs`, `ps`, `ns`, `us`, `ms`, `s`) and integer magnitudes.
- Parsed times are normalized to dump `time_unit`; time-window flags (`--from`, `--to`) use inclusive boundaries.
- Errors follow `error: <category>: <message>`.
Use `wavepeek <command> --help` or `wavepeek help <command-path...>` for full command reference help, `wavepeek docs` for narrative guidance, and `wavepeek skill` for the packaged agent skill."#,
after_help = "Next steps:\n wavepeek --help\n wavepeek help <command-path...>\n wavepeek docs\n wavepeek skill",
after_long_help = "Next steps:\n wavepeek --help\n wavepeek help <command-path...>\n wavepeek docs\n wavepeek skill",
help_template = "{about-with-newline}\nUsage: {usage}\n\nWaveform commands:\n info Show waveform metadata\n scope Explore hierarchy scopes\n signal Explore signals within scope\n value Get signal values at a specific time point\n change List signal changes over a time range\n property Evaluate properties over a time range\n\nHelper commands:\n schema Print canonical JSON schema contract\n docs Browse embedded documentation\n skill Print packaged agent skill Markdown\n help Show help for the given subcommand(s)\n\nOptions:\n{options}{after-help}"
)]
pub struct Cli {
/// Print semver version
#[arg(short = 'V', action = ArgAction::SetTrue)]
version_semver: bool,
/// Print full version
#[arg(long = "version", action = ArgAction::SetTrue)]
version_full: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
#[command(flatten, next_help_heading = "Waveform commands")]
Waveform(WaveformCommand),
#[command(flatten, next_help_heading = "Helper commands")]
Helper(HelperCommand),
}
#[derive(Debug, Subcommand)]
enum WaveformCommand {
#[command(
about = "Reports metadata for the selected waveform dump.",
long_about = r#"Reports metadata for the selected waveform dump.
Behavior:
- Prints available metadata (e.g. time unit, start/end times, etc.) in free form
- `--json` uses the machine contract defined by `wavepeek schema`."#,
after_long_help = "See also:\n wavepeek docs show commands/info"
)]
Info(info::InfoArgs),
#[command(
about = "Provides deterministic hierarchy traversal over scope paths.",
long_about = r#"Provides deterministic hierarchy traversal over scope paths.
Behavior:
- Finds all scopes matching `--filter` and displays scope name, depth, and kind.
- Traversal order is stable: pre-order depth-first, with lexicographic child ordering.
- Includes parser-native scope kinds from hierarchy data (not only modules).
- `--tree` switches from flat list to visual hierarchy rendering.
- Truncation and disabled-limit conditions emit warnings.
- `--json` uses the machine contract defined by `wavepeek schema`.
Use this command to explore hierarchy shape before narrowing to signal-level queries."#,
after_long_help = "See also:\n wavepeek docs show commands/scope"
)]
Scope(scope::ScopeArgs),
#[command(
about = "Provides scope-local signal listings.",
long_about = r#"Provides scope-local signal listings.
Behavior:
- Finds all signals matching `--filter` within the selected scope and displays name, kind, and available metadata (for example width).
- Default mode lists only direct signals in the selected scope.
- Recursive mode walks child scopes depth-first in stable lexicographic order; `--max-depth` limits recursion when set.
- Includes parser-native signal kinds (not only wires).
- Truncation and disabled-limit conditions emit warnings.
- `--json` uses the machine contract defined by `wavepeek schema`.
Use this command after `scope` to inspect available signals in a target scope."#
)]
Signal(signal::SignalArgs),
#[command(
about = "Provides point-in-time sampling for selected signals.",
long_about = r#"Provides point-in-time sampling for selected signals.
Behavior:
- Prints values for the requested signals at the selected time point.
- By default, signal names are top-related canonical paths (e.g. `top.cpu.state`).
- For deep hierarchies, set `--scope` once with a canonical scope path and use shorter scope-relative names in `--signals`.
- Do not mix top-related canonical names and scope-relative names in one request.
- Output preserves the input order from `--signals`.
- Time tokens must include explicit units and align to dump precision.
- Values are emitted as Verilog literals (`<width>'h<digits>` with `x`/`z` support).
- Fails fast if any requested signal cannot be resolved or if the selected time point is more precise than dump resolution.
- `--json` uses the machine contract defined by `wavepeek schema`.
Use this command for deterministic spot checks at a specific timestamp."#
)]
Value(value::ValueArgs),
#[command(
about = "Provides range-based delta snapshots for selected signals.",
long_about = r#"Provides range-based delta snapshots for selected signals.
Behavior:
- Prints requested signal values for each `--on` trigger firing when at least one value changed since the previous firing.
- Similar to a modified SystemVerilog `$monitor`, but with print trigger control instead of printing at every timestamp.
- Range boundaries are inclusive; baseline state is initialized at range start.
- Rows are emitted only when sampled signal values changed from prior sampled state.
- Empty-result and truncation conditions may emit warnings.
- `--json` uses the machine contract defined by `wavepeek schema`.
Use this command to inspect value transitions over bounded time windows."#
)]
Change(change::ChangeArgs),
#[command(
about = "Provides timestamps where the specified property holds over event triggers.",
long_about = r#"Provides timestamps where the specified property holds over event triggers.
Behavior:
- Evaluates `--eval` at timestamps selected by `--on` and prints time plus metadata when the property holds.
- Level capture (`--capture match`) reports a match at every selected timestamp where the property holds.
- Edge capture (`--capture switch`, `assert`, or `deassert`) reports transitions: no match to match, or match to no match.
- Remotely similar to a SystemVerilog assert, but without temporal expressions.
- `--json` uses the machine contract defined by `wavepeek schema`.
Use this command to check event-driven property matches and transitions over bounded time windows."#
)]
Property(property::PropertyArgs),
}
#[derive(Debug, Subcommand)]
enum HelperCommand {
#[command(
about = "Print canonical JSON schema contract.",
long_about = r#"Print canonical JSON schema contract.
Behavior:
- Prints exactly one deterministic schema document to stdout.
- This is the source of truth for all `--json` command outputs.
Use this command to fetch the machine-readable contract consumed by JSON-mode clients."#
)]
Schema(schema::SchemaArgs),
Docs(docs::DocsArgs),
#[command(
about = "Print the packaged agent skill Markdown for wavepeek.",
long_about = "Print the packaged agent skill Markdown for wavepeek."
)]
Skill(skill::SkillArgs),
}
pub fn run() -> Result<(), WavepeekError> {
let argv: Vec<_> = std::env::args_os().collect();
let parse_argv = if argv.len() == 1 {
vec![argv[0].clone(), "-h".into()]
} else if argv.len() == 2 && argv[1] == std::ffi::OsStr::new("docs") {
vec![argv[0].clone(), "docs".into(), "-h".into()]
} else {
argv
};
let matches = match build_cli_command().try_get_matches_from(parse_argv) {
Ok(matches) => matches,
Err(error) => return handle_parse_error(error),
};
if change_tune_overrides_requested(&matches) && !is_debug_mode_enabled() {
return Err(WavepeekError::Args(
"internal tuning overrides (--tune-*) require DEBUG=1. Set DEBUG=1 only for local diagnostics or CI debugging."
.to_string(),
));
}
let cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(error) => return handle_parse_error(error),
};
if cli.version_semver {
println!(env!("CARGO_PKG_VERSION"));
return Ok(());
}
if cli.version_full {
println!("wavepeek v{}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let Some(command) = cli.command else {
return Ok(());
};
dispatch(command)
}
fn is_debug_mode_enabled() -> bool {
std::env::var("DEBUG")
.map(|value| value == "1")
.unwrap_or(false)
}
fn change_tune_overrides_requested(matches: &clap::ArgMatches) -> bool {
let Some(("change", change_matches)) = matches.subcommand() else {
return false;
};
is_command_line_override(change_matches, "tune_engine")
|| is_command_line_override(change_matches, "tune_candidates")
|| is_command_line_override(change_matches, "tune_edge_fast_force")
}
fn is_command_line_override(matches: &clap::ArgMatches, arg: &str) -> bool {
matches!(matches.value_source(arg), Some(ValueSource::CommandLine))
}
fn build_cli_command() -> clap::Command {
let mut command = Cli::command();
if let Some(help) = command.find_subcommand_mut("help") {
*help = help.clone().about("Show help for the given subcommand(s)");
}
for command_name in ["info", "scope", "signal", "value", "change", "property"] {
if let Some(subcommand) = command.find_subcommand_mut(command_name) {
*subcommand = with_other_help_options(subcommand.clone());
}
}
command
}
fn with_other_help_options(command: clap::Command) -> clap::Command {
command
.disable_help_flag(true)
.arg(
Arg::new("help_short")
.short('h')
.action(ArgAction::HelpShort)
.help("Print help (see more with '--help')")
.help_heading("Other options"),
)
.arg(
Arg::new("help_long")
.long("help")
.action(ArgAction::HelpLong)
.help("Print help (see a summary with '-h')")
.help_heading("Other options"),
)
}
fn handle_parse_error(error: clap::Error) -> Result<(), WavepeekError> {
match error.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => error.print().map_err(|io_error| {
WavepeekError::Internal(format!("failed to print help: {io_error}"))
}),
_ => Err(WavepeekError::Args(normalize_clap_error(&error))),
}
}
fn normalize_clap_error(error: &clap::Error) -> String {
let rendered = error.render().to_string();
let detail = clap_error_detail(rendered.as_str());
let hint = help_hint_for_rendered_clap_error(rendered.as_str());
format!("{detail} {hint}")
}
fn clap_error_detail(rendered: &str) -> String {
let lines: Vec<&str> = rendered.lines().collect();
if let Some(start_index) = lines
.iter()
.position(|line| line.trim_start().starts_with("error:"))
{
let mut chunks = Vec::new();
for (index, line) in lines.iter().enumerate().skip(start_index) {
let trimmed = line.trim();
if index > start_index
&& (trimmed.starts_with("Usage:") || trimmed.starts_with("For more information"))
{
break;
}
if index == start_index {
if let Some(rest) = trimmed.strip_prefix("error:") {
let rest = rest.trim();
if !rest.is_empty() {
chunks.push(rest.to_string());
}
}
continue;
}
if !trimmed.is_empty() {
chunks.push(trimmed.to_string());
}
}
if !chunks.is_empty() {
return chunks.join(" ");
}
}
for line in lines {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("error:") {
return rest.trim().to_string();
}
}
rendered
.lines()
.find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.unwrap_or_else(|| "invalid arguments".to_string())
}
fn help_hint_for_rendered_clap_error(rendered: &str) -> String {
let usage_line = rendered
.lines()
.map(str::trim)
.find(|line| line.starts_with("Usage:"));
let Some(usage_line) = usage_line else {
return "See 'wavepeek --help'.".to_string();
};
let usage = usage_line.trim_start_matches("Usage:").trim();
let mut parts = usage.split_whitespace();
let Some(command_name) = parts.next() else {
return "See 'wavepeek --help'.".to_string();
};
if command_name != "wavepeek" {
return "See 'wavepeek --help'.".to_string();
}
let mut path_tokens = Vec::new();
for token in parts {
if token.starts_with('[') || token.starts_with('<') || token.starts_with('-') {
break;
}
path_tokens.push(token);
}
if path_tokens.is_empty() {
return "See 'wavepeek --help'.".to_string();
}
format!("See 'wavepeek {} --help'.", path_tokens.join(" "))
}
fn dispatch(command: Command) -> Result<(), WavepeekError> {
let engine_command = into_engine_command(command);
let result = engine::run(engine_command)?;
output::write(result)
}
fn into_engine_command(command: Command) -> EngineCommand {
match command {
Command::Waveform(command) => match command {
WaveformCommand::Info(args) => EngineCommand::Info(args),
WaveformCommand::Scope(args) => EngineCommand::Scope(args),
WaveformCommand::Signal(args) => EngineCommand::Signal(args),
WaveformCommand::Value(args) => EngineCommand::Value(args),
WaveformCommand::Change(args) => EngineCommand::Change(args),
WaveformCommand::Property(args) => EngineCommand::Property(args),
},
Command::Helper(command) => match command {
HelperCommand::Schema(args) => EngineCommand::Schema(args),
HelperCommand::Docs(args) => EngineCommand::Docs(args),
HelperCommand::Skill(args) => EngineCommand::Skill(args),
},
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use clap::Parser;
use crate::cli::limits::LimitArg;
use super::{
Cli, EngineCommand, clap_error_detail, help_hint_for_rendered_clap_error,
into_engine_command, normalize_clap_error,
};
#[test]
fn info_dispatch_keeps_json_and_waves_args() {
let cli = Cli::parse_from([
"wavepeek",
"info",
"--waves",
"fixtures/sample.vcd",
"--json",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Info(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert!(args.json);
}
other => panic!("expected info command, got {other:?}"),
}
}
#[test]
fn scope_dispatch_keeps_bounded_query_args() {
let cli = Cli::parse_from([
"wavepeek",
"scope",
"--waves",
"fixtures/sample.vcd",
"--max",
"12",
"--max-depth",
"3",
"--filter",
"^top\\..*",
"--tree",
"--json",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Scope(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert_eq!(args.max, LimitArg::Numeric(12));
assert_eq!(args.max_depth, LimitArg::Numeric(3));
assert_eq!(args.filter, "^top\\..*");
assert!(args.tree);
assert!(args.json);
}
other => panic!("expected scope command, got {other:?}"),
}
}
#[test]
fn scope_dispatch_accepts_unlimited_limit_literals() {
let cli = Cli::parse_from([
"wavepeek",
"scope",
"--waves",
"fixtures/sample.vcd",
"--max",
"unlimited",
"--max-depth",
"unlimited",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Scope(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert_eq!(args.max, LimitArg::Unlimited);
assert_eq!(args.max_depth, LimitArg::Unlimited);
}
other => panic!("expected scope command, got {other:?}"),
}
}
#[test]
fn signal_dispatch_keeps_recursive_and_max_depth_args() {
let cli = Cli::parse_from([
"wavepeek",
"signal",
"--waves",
"fixtures/sample.vcd",
"--scope",
"top.cpu",
"--recursive",
"--max-depth",
"3",
"--max",
"7",
"--filter",
".*clk.*",
"--abs",
"--json",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Signal(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert_eq!(args.scope, "top.cpu");
assert!(args.recursive);
assert_eq!(args.max_depth, LimitArg::Numeric(3));
assert_eq!(args.max, LimitArg::Numeric(7));
assert_eq!(args.filter, ".*clk.*");
assert!(args.abs);
assert!(args.json);
}
other => panic!("expected signal command, got {other:?}"),
}
}
#[test]
fn value_dispatch_keeps_scope_signals_abs_and_json_args() {
let cli = Cli::parse_from([
"wavepeek",
"value",
"--waves",
"fixtures/sample.vcd",
"--at",
"10ns",
"--scope",
"top",
"--signals",
"clk,data",
"--abs",
"--json",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Value(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert_eq!(args.at, "10ns");
assert_eq!(args.scope.as_deref(), Some("top"));
assert_eq!(args.signals, vec!["clk", "data"]);
assert!(args.abs);
assert!(args.json);
}
other => panic!("expected value command, got {other:?}"),
}
}
#[test]
fn change_dispatch_keeps_on_abs_and_limits() {
let cli = Cli::parse_from([
"wavepeek",
"change",
"--waves",
"fixtures/sample.vcd",
"--from",
"1ns",
"--to",
"10ns",
"--scope",
"top",
"--signals",
"clk,data",
"--on",
"posedge clk",
"--max",
"12",
"--abs",
"--json",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Change(args) => {
assert_eq!(args.waves, PathBuf::from("fixtures/sample.vcd"));
assert_eq!(args.from.as_deref(), Some("1ns"));
assert_eq!(args.to.as_deref(), Some("10ns"));
assert_eq!(args.scope.as_deref(), Some("top"));
assert_eq!(args.signals, vec!["clk", "data"]);
assert_eq!(args.on.as_deref(), Some("posedge clk"));
assert_eq!(args.max, LimitArg::Numeric(12));
assert!(args.abs);
assert!(args.json);
}
other => panic!("expected change command, got {other:?}"),
}
}
#[test]
fn property_dispatch_parses_capture_default() {
let cli = Cli::parse_from([
"wavepeek",
"property",
"--waves",
"fixtures/sample.vcd",
"--on",
"posedge top.clk",
"--eval",
"1",
]);
let command = into_engine_command(cli.command.expect("parsed command"));
match command {
EngineCommand::Property(args) => {
assert_eq!(args.on.as_deref(), Some("posedge top.clk"));
assert_eq!(args.eval, "1");
assert_eq!(args.capture, crate::cli::property::CaptureMode::Switch);
}
other => panic!("expected property command, got {other:?}"),
}
}
#[test]
fn clap_errors_are_normalized_to_single_line_message() {
let error = Cli::try_parse_from(["wavepeek", "schema", "--waves", "dump.vcd"])
.expect_err("schema --waves should fail");
let normalized = normalize_clap_error(&error);
assert!(normalized.contains("unexpected argument '--waves'"));
assert!(normalized.contains("See 'wavepeek schema --help'."));
assert!(!normalized.contains("Usage:"));
}
#[test]
fn clap_error_detail_preserves_missing_argument_names() {
let rendered = "error: the following required arguments were not provided:\n --waves <FILE>\n\nUsage: wavepeek info --waves <FILE>\n\nFor more information, try '--help'.\n";
let normalized = clap_error_detail(rendered);
assert!(normalized.contains("the following required arguments were not provided"));
assert!(normalized.contains("--waves <FILE>"));
assert!(!normalized.contains("Usage:"));
}
#[test]
fn help_hint_uses_global_help_for_top_level_parse_failures() {
let rendered = "error: unexpected argument '--wat' found\n\nUsage: wavepeek [OPTIONS] <COMMAND>\n\nFor more information, try '--help'.\n";
let hint = help_hint_for_rendered_clap_error(rendered);
assert_eq!(hint, "See 'wavepeek --help'.");
}
#[test]
fn help_hint_uses_subcommand_help_for_subcommand_parse_failures() {
let rendered = "error: unexpected argument '--wat' found\n\nUsage: wavepeek info --waves <FILE>\n\nFor more information, try '--help'.\n";
let hint = help_hint_for_rendered_clap_error(rendered);
assert_eq!(hint, "See 'wavepeek info --help'.");
}
}