use std::env;
use std::ffi::OsString;
use std::fs;
#[cfg(test)]
use std::fs::File;
use std::io::{self, BufRead, IsTerminal, Write};
use std::iter;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::{Args, Parser, Subcommand};
use snafu::{ResultExt, Snafu};
use super::report_terminal::{
InspectSelection, InspectSort, TerminalInspectError, TerminalProfileIndex,
};
const DEFAULT_INSPECT_LIMIT: u16 = 20;
const MAX_INSPECT_LIMIT: u16 = 200;
const MAX_INSPECT_DEPTH: u16 = 32;
const MAX_FILTER_CHARS: usize = 128;
const MAX_INTERACTIVE_COMMAND_BYTES: usize = 1024;
const INTERACTIVE_END_MARKER: &str = "-- end --\n";
#[derive(Debug, Parser, PartialEq, Eq)]
#[command(
name = "delta-funnel-perfetto",
about = "Generate and inspect Delta Funnel Perfetto diagnostics",
disable_version_flag = true
)]
struct PerfettoCli {
#[command(subcommand)]
command: PerfettoCommand,
}
#[derive(Debug, PartialEq, Eq, Subcommand)]
enum PerfettoCommand {
Report(RankedReportArgs),
Inspect(InspectArgs),
}
#[derive(Args, Debug, PartialEq, Eq)]
struct RankedReportArgs {
#[arg(value_name = "INPUT")]
input: PathBuf,
#[arg(long, value_name = "OUTPUT.profile.html", allow_hyphen_values = true)]
output: Option<PathBuf>,
#[arg(long, value_name = "OUTPUT.dfprofile", allow_hyphen_values = true)]
artifact_output: Option<PathBuf>,
#[arg(long)]
no_clobber: bool,
}
#[derive(Args, Debug, PartialEq, Eq)]
struct InspectArgs {
#[arg(value_name = "INPUT.dfprofile")]
input: PathBuf,
#[arg(
long,
default_value_t = DEFAULT_INSPECT_LIMIT,
allow_negative_numbers = true,
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_INSPECT_LIMIT))
)]
limit: u16,
#[arg(long, value_name = "ID", allow_negative_numbers = true)]
semantic: Option<i64>,
#[arg(
long,
value_name = "SEMANTIC_ID:FUNCTION_ID",
allow_hyphen_values = true,
conflicts_with = "semantic"
)]
function: Option<FunctionSelector>,
#[arg(long, value_name = "TEXT")]
filter: Option<FilterText>,
#[arg(long, value_enum)]
sort: Option<InspectSort>,
#[arg(
long,
allow_negative_numbers = true,
value_parser = clap::value_parser!(u16).range(0..=i64::from(MAX_INSPECT_DEPTH))
)]
depth: Option<u16>,
#[arg(long)]
interactive: bool,
#[arg(long)]
full_symbols: bool,
#[arg(long)]
all_frames: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FunctionSelector {
semantic_id: i64,
function_id: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct FilterText(String);
impl FromStr for FilterText {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() || value.chars().count() > MAX_FILTER_CHARS {
return Err("filter must contain between 1 and 128 characters");
}
Ok(Self(value.to_owned()))
}
}
struct InspectState {
selection: InspectSelection,
sort: InspectSort,
filter: Option<String>,
limit: usize,
depth: Option<usize>,
full_symbols: bool,
all_frames: bool,
}
impl InspectState {
fn render(&self, index: &TerminalProfileIndex<'_>) -> Result<String, TerminalInspectError> {
index.render(
self.selection,
self.sort,
self.filter.as_deref(),
self.limit,
self.depth
.unwrap_or_else(|| usize::from(self.selection != InspectSelection::Root)),
)
}
}
impl FromStr for FunctionSelector {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (semantic_id, function_id) = value
.split_once(':')
.ok_or("function identity must contain one colon")?;
Ok(Self {
semantic_id: semantic_id
.parse()
.map_err(|_| "semantic function owner must be a signed integer")?,
function_id: function_id
.parse()
.map_err(|_| "function ID must be a signed integer")?,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Snafu)]
enum CliArgumentError {
#[snafu(display("a diagnostics command is required"))]
MissingCommand,
#[snafu(display("unknown diagnostics command"))]
UnknownCommand,
#[snafu(display("a profile input path is required"))]
MissingInput,
#[snafu(display("--output requires a path"))]
MissingOutputValue,
#[snafu(display("--output may be specified only once"))]
DuplicateOutput,
#[snafu(display("--artifact-output requires a path"))]
MissingArtifactOutputValue,
#[snafu(display("--artifact-output may be specified only once"))]
DuplicateArtifactOutput,
#[snafu(display("only one profile input path may be provided"))]
MultipleInputs,
#[snafu(display("limit must be between 1 and 200"))]
InvalidLimit,
#[snafu(display("depth must be between 0 and 32"))]
InvalidDepth,
#[snafu(display("semantic ID must be a signed integer"))]
InvalidSemanticId,
#[snafu(display("function ID must use SEMANTIC_ID:FUNCTION_ID signed integers"))]
InvalidFunctionId,
#[snafu(display("filter must contain between 1 and 128 characters"))]
InvalidFilter,
#[snafu(display("sort must be duration, inclusive-cpu, self-cpu, or name"))]
InvalidSort,
#[snafu(display("--semantic and --function cannot be used together"))]
IncompatibleSelectors,
#[snafu(display("function callsites cannot be sorted by exact duration"))]
IncompatibleSort,
#[snafu(display("option may be specified only once"))]
DuplicateOption,
#[snafu(display("unknown option"))]
UnknownOption,
}
impl CliArgumentError {
const fn kind(self) -> &'static str {
match self {
Self::MissingCommand => "missing_command",
Self::UnknownCommand => "unknown_command",
Self::MissingInput => "missing_input",
Self::MissingOutputValue => "missing_output_value",
Self::DuplicateOutput => "duplicate_output",
Self::MissingArtifactOutputValue => "missing_artifact_output_value",
Self::DuplicateArtifactOutput => "duplicate_artifact_output",
Self::MultipleInputs => "multiple_inputs",
Self::InvalidLimit => "invalid_limit",
Self::InvalidDepth => "invalid_depth",
Self::InvalidSemanticId => "invalid_semantic_id",
Self::InvalidFunctionId => "invalid_function_id",
Self::InvalidFilter => "invalid_filter",
Self::InvalidSort => "invalid_sort",
Self::IncompatibleSelectors => "incompatible_selectors",
Self::IncompatibleSort => "incompatible_sort",
Self::DuplicateOption => "duplicate_option",
Self::UnknownOption => "unknown_option",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RankedReportFailurePhase {
Argument,
Input,
Health,
TraceProcessor,
Query,
AggregateValidation,
Serialization,
Output,
}
impl RankedReportFailurePhase {
pub fn as_str(self) -> &'static str {
match self {
Self::Argument => "argument",
Self::Input => "input",
Self::Health => "health",
Self::TraceProcessor => "trace_processor",
Self::Query => "query",
Self::AggregateValidation => "aggregate_validation",
Self::Serialization => "serialization",
Self::Output => "output",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Snafu)]
#[snafu(display("{message}"))]
pub struct RankedReportFailure {
phase: RankedReportFailurePhase,
kind: &'static str,
message: String,
}
impl RankedReportFailure {
pub(super) fn new(
phase: RankedReportFailurePhase,
kind: &'static str,
message: impl Into<String>,
) -> Self {
Self {
phase,
kind,
message: message.into(),
}
}
pub fn phase(&self) -> RankedReportFailurePhase {
self.phase
}
pub fn kind(&self) -> &'static str {
self.kind
}
pub fn machine_line(&self) -> String {
serde_json::json!({
"phase": self.phase.as_str(),
"kind": self.kind,
"message": self.message,
})
.to_string()
}
}
impl From<CliArgumentError> for RankedReportFailure {
fn from(error: CliArgumentError) -> Self {
Self::new(
RankedReportFailurePhase::Argument,
error.kind(),
error.to_string(),
)
}
}
impl From<TerminalInspectError> for RankedReportFailure {
fn from(error: TerminalInspectError) -> Self {
Self::new(
RankedReportFailurePhase::Argument,
error.kind(),
error.to_string(),
)
}
}
#[derive(Debug, Snafu)]
pub(super) enum RankedReportPathError {
#[snafu(display("profile input is not readable: {source}"))]
InputUnreadable { source: io::Error },
#[snafu(display("profile input is not a file"))]
InputNotFile,
#[snafu(display("output path has no file name"))]
OutputHasNoFileName,
#[snafu(display("existing output is not a file"))]
OutputNotFile,
#[snafu(display("output parent path is not a directory"))]
OutputParentNotDirectory,
#[snafu(display("output path could not be inspected: {source}"))]
OutputInspection { source: io::Error },
#[snafu(display("profile input and output resolve to the same file"))]
InputOutputAlias,
}
impl From<RankedReportPathError> for RankedReportFailure {
fn from(error: RankedReportPathError) -> Self {
let (phase, kind) = match &error {
RankedReportPathError::InputUnreadable { .. } => {
(RankedReportFailurePhase::Input, "unreadable")
}
RankedReportPathError::InputNotFile => (RankedReportFailurePhase::Input, "not_file"),
RankedReportPathError::OutputHasNoFileName => {
(RankedReportFailurePhase::Output, "missing_file_name")
}
RankedReportPathError::OutputNotFile => (RankedReportFailurePhase::Output, "not_file"),
RankedReportPathError::OutputParentNotDirectory => {
(RankedReportFailurePhase::Output, "parent_not_directory")
}
RankedReportPathError::OutputInspection { .. } => {
(RankedReportFailurePhase::Output, "inspection_failed")
}
RankedReportPathError::InputOutputAlias => {
(RankedReportFailurePhase::Output, "aliases_input")
}
};
Self::new(phase, kind, error.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct RankedReportPaths {
pub(super) input: PathBuf,
pub(super) output: PathBuf,
}
pub fn run_perfetto_diagnostics_cli() -> i32 {
run_perfetto_diagnostics_cli_with_args(env::args_os().skip(1))
}
pub fn run_perfetto_diagnostics_cli_with_args(args: impl IntoIterator<Item = OsString>) -> i32 {
let args = args.into_iter().collect::<Vec<_>>();
if args.first().is_some_and(|argument| argument == "report")
&& let Some(error) = first_report_argument_error(&args[1..])
{
return emit_failure(error.into());
}
match PerfettoCli::try_parse_from(
iter::once(OsString::from("delta-funnel-perfetto")).chain(args.iter().cloned()),
) {
Ok(PerfettoCli {
command: PerfettoCommand::Report(args),
}) => run_report_command(args),
Ok(PerfettoCli {
command: PerfettoCommand::Inspect(args),
}) => run_inspect_command(args),
Err(error) if matches!(error.kind(), ErrorKind::DisplayHelp) => error
.print()
.map_or_else(|_| emit_failure(terminal_output_failure()), |()| 0),
Err(error) => emit_failure(cli_failure(&args, &error)),
}
}
fn run_inspect_command(args: InspectArgs) -> i32 {
let selection = if let Some(function) = args.function {
InspectSelection::Function {
semantic_id: function.semantic_id,
function_id: function.function_id,
}
} else if let Some(semantic_id) = args.semantic {
InspectSelection::Semantic(semantic_id)
} else {
InspectSelection::Root
};
let sort = args.sort.unwrap_or(match selection {
InspectSelection::Function { .. } => InspectSort::InclusiveCpu,
InspectSelection::Root | InspectSelection::Semantic(_) => InspectSort::Duration,
});
if matches!(selection, InspectSelection::Function { .. }) && sort == InspectSort::Duration {
return emit_failure(CliArgumentError::IncompatibleSort.into());
}
let mut state = InspectState {
selection,
sort,
filter: args.filter.map(|filter| filter.0),
limit: usize::from(args.limit),
depth: args.depth.map(usize::from),
full_symbols: args.full_symbols,
all_frames: args.all_frames,
};
let input = match preflight_ranked_profile_input(&args.input) {
Ok(input) => input,
Err(error) => return emit_failure(error.into()),
};
match super::load_ranked_profile_artifact_input(&input) {
Ok(document) if args.interactive => {
let stdin = io::stdin();
let stdout = io::stdout();
let prompt = stdin.is_terminal() && stdout.is_terminal();
let mut input = stdin.lock();
let mut output = stdout.lock();
let mut error = io::stderr().lock();
match run_interactive_session(
&document,
&mut state,
&mut input,
&mut output,
&mut error,
prompt,
) {
Ok(()) => 0,
Err(error) => emit_failure(error),
}
}
Ok(document) => {
match state.render(&TerminalProfileIndex::new(
&document,
state.full_symbols,
state.all_frames,
)) {
Ok(output) => {
let mut stdout = io::stdout().lock();
match stdout
.write_all(output.as_bytes())
.and_then(|()| stdout.flush())
{
Ok(()) => 0,
Err(_) => emit_failure(terminal_output_failure()),
}
}
Err(error) => emit_failure(error.into()),
}
}
Err(error) => emit_failure(error),
}
}
fn run_interactive_session(
document: &super::ranked_report::RankedProfileDocument,
state: &mut InspectState,
input: &mut impl BufRead,
output: &mut impl Write,
error: &mut impl Write,
prompt: bool,
) -> Result<(), RankedReportFailure> {
let index = TerminalProfileIndex::new(document, state.full_symbols, state.all_frames);
let initial = state.render(&index).map_err(RankedReportFailure::from)?;
write_interactive_response(output, &initial)?;
let mut line = Vec::with_capacity(MAX_INTERACTIVE_COMMAND_BYTES);
loop {
if prompt {
output
.write_all(b"profile> ")
.and_then(|()| output.flush())
.map_err(|_| terminal_output_failure())?;
}
match read_interactive_line(input, &mut line).map_err(|_| interactive_input_failure())? {
InteractiveLine::Eof => return Ok(()),
InteractiveLine::Invalid(message) => {
write_interactive_error(error, message)?;
write_interactive_response(output, "")?;
}
InteractiveLine::Command(command) => {
match run_interactive_command(&index, state, command.trim()) {
Ok(InteractiveCommandResult::Output(response)) => {
write_interactive_response(output, &response)?;
}
Ok(InteractiveCommandResult::Quit) => {
write_interactive_response(output, "")?;
return Ok(());
}
Err(message) => {
write_interactive_error(error, message)?;
write_interactive_response(output, "")?;
}
}
}
}
}
}
enum InteractiveCommandResult {
Output(String),
Quit,
}
fn run_interactive_command(
index: &TerminalProfileIndex<'_>,
state: &mut InspectState,
command: &str,
) -> Result<InteractiveCommandResult, &'static str> {
match command {
"show" => state
.render(index)
.map(InteractiveCommandResult::Output)
.map_err(|_| "current profile selection does not exist"),
"up" => {
state.selection = index.up(state.selection)?;
render_interactive_selection(index, state)
}
"root" => {
state.selection = InspectSelection::Root;
render_interactive_selection(index, state)
}
"clear" => {
state.filter = None;
render_interactive_selection(index, state)
}
"help" => Ok(InteractiveCommandResult::Output(
"commands: show, open semantic:ID, open function:SEMANTIC_ID:FUNCTION_ID, up, root, sort METRIC, filter TEXT, clear, limit N, help, quit\n"
.to_owned(),
)),
"quit" => Ok(InteractiveCommandResult::Quit),
_ => {
if command == "filter" || command.starts_with("filter ") {
let value = command
.strip_prefix("filter ")
.ok_or("filter must contain between 1 and 128 characters")?;
let filter = value
.parse::<FilterText>()
.map_err(|_| "filter must contain between 1 and 128 characters")?;
state.filter = Some(filter.0);
return render_interactive_selection(index, state);
}
if command == "sort" || command.starts_with("sort ") {
let value = command
.strip_prefix("sort ")
.ok_or("invalid sort; expected sort METRIC")?;
let sort = match value {
"duration" => InspectSort::Duration,
"inclusive-cpu" => InspectSort::InclusiveCpu,
"self-cpu" => InspectSort::SelfCpu,
"name" => InspectSort::Name,
_ => {
return Err(
"invalid sort; expected duration, inclusive-cpu, self-cpu, or name",
);
}
};
if sort == InspectSort::Duration
&& matches!(state.selection, InspectSelection::Function { .. })
{
return Err("function callsites cannot be sorted by exact duration");
}
state.sort = sort;
return render_interactive_selection(index, state);
}
if command == "limit" || command.starts_with("limit ") {
let value = command
.strip_prefix("limit ")
.ok_or("invalid limit; expected limit N")?;
let limit = value
.parse::<u16>()
.ok()
.filter(|limit| (1..=MAX_INSPECT_LIMIT).contains(limit))
.ok_or("limit must be between 1 and 200")?;
state.limit = usize::from(limit);
return render_interactive_selection(index, state);
}
let target = command
.strip_prefix("open ")
.ok_or("unknown interactive command")?;
if let Some(semantic_id) = target.strip_prefix("semantic:") {
let semantic_id = semantic_id
.parse()
.map_err(|_| "invalid semantic identity; expected semantic:ID")?;
state.selection = index.open_semantic(state.selection, semantic_id)?;
} else if let Some(function) = target.strip_prefix("function:") {
let function = function.parse::<FunctionSelector>().map_err(|_| {
"invalid function identity; expected function:SEMANTIC_ID:FUNCTION_ID"
})?;
state.selection = index.open_function(
state.selection,
function.semantic_id,
function.function_id,
)?;
if state.sort == InspectSort::Duration {
state.sort = InspectSort::InclusiveCpu;
}
} else {
return Err(
"invalid open target; expected semantic:ID or function:SEMANTIC_ID:FUNCTION_ID",
);
}
render_interactive_selection(index, state)
}
}
}
fn render_interactive_selection(
index: &TerminalProfileIndex<'_>,
state: &InspectState,
) -> Result<InteractiveCommandResult, &'static str> {
state
.render(index)
.map(InteractiveCommandResult::Output)
.map_err(|_| "current profile selection does not exist")
}
enum InteractiveLine {
Eof,
Command(String),
Invalid(&'static str),
}
fn read_interactive_line(
input: &mut impl BufRead,
line: &mut Vec<u8>,
) -> io::Result<InteractiveLine> {
line.clear();
let mut read_any = false;
let mut exceeded_limit = false;
loop {
let available = input.fill_buf()?;
if available.is_empty() {
break;
}
read_any = true;
let newline = available.iter().position(|byte| *byte == b'\n');
let consumed = newline.map_or(available.len(), |position| position + 1);
let payload = newline.unwrap_or(consumed);
if !exceeded_limit {
let retained = payload.min(
MAX_INTERACTIVE_COMMAND_BYTES
.saturating_add(1)
.saturating_sub(line.len()),
);
line.extend_from_slice(&available[..retained]);
exceeded_limit |= retained != payload || line.len() > MAX_INTERACTIVE_COMMAND_BYTES;
}
input.consume(consumed);
if newline.is_some() {
break;
}
}
if !read_any {
return Ok(InteractiveLine::Eof);
}
if exceeded_limit {
return Ok(InteractiveLine::Invalid(
"interactive command exceeds the 1024-byte limit",
));
}
if line.last() == Some(&b'\r') {
line.pop();
}
match std::str::from_utf8(line) {
Ok(line) => Ok(InteractiveLine::Command(line.to_owned())),
Err(_) => Ok(InteractiveLine::Invalid(
"interactive command must be valid UTF-8",
)),
}
}
fn write_interactive_response(
output: &mut impl Write,
response: &str,
) -> Result<(), RankedReportFailure> {
output
.write_all(response.as_bytes())
.and_then(|()| {
if !response.is_empty() && !response.ends_with('\n') {
output.write_all(b"\n")?;
}
output.write_all(INTERACTIVE_END_MARKER.as_bytes())
})
.and_then(|()| output.flush())
.map_err(|_| terminal_output_failure())
}
fn write_interactive_error(
error: &mut impl Write,
message: &str,
) -> Result<(), RankedReportFailure> {
writeln!(error, "error: {message}")
.and_then(|()| error.flush())
.map_err(|_| terminal_output_failure())
}
fn interactive_input_failure() -> RankedReportFailure {
RankedReportFailure::new(
RankedReportFailurePhase::Input,
"interactive_read_failed",
"interactive command input could not be read",
)
}
fn terminal_output_failure() -> RankedReportFailure {
RankedReportFailure::new(
RankedReportFailurePhase::Output,
"terminal_write_failed",
"terminal output could not be written",
)
}
fn run_report_command(args: RankedReportArgs) -> i32 {
let RankedReportArgs {
input,
output,
artifact_output,
no_clobber,
} = args;
let output = output.unwrap_or_else(|| default_report_path(&input));
let generated = if no_clobber {
super::generate_ranked_profile_report_without_clobber(
&input,
&output,
artifact_output.as_deref(),
)
} else {
super::generate_ranked_profile_report_outputs(&input, &output, artifact_output.as_deref())
};
match generated {
Ok(output) => {
let mut stdout = io::stdout().lock();
writeln!(stdout, "wrote {}", output.display())
.and_then(|()| stdout.flush())
.map_or_else(|_| emit_failure(terminal_output_failure()), |()| 0)
}
Err(error) => emit_failure(error),
}
}
fn classify_cli_error(args: &[OsString], error: &clap::Error) -> CliArgumentError {
let Some(command) = args.first() else {
return CliArgumentError::MissingCommand;
};
if command != "report" && command != "inspect" {
return CliArgumentError::UnknownCommand;
}
if command == "report"
&& let Some(error) = first_report_argument_error(&args[1..])
{
return error;
}
match error.kind() {
ErrorKind::MissingRequiredArgument => CliArgumentError::MissingInput,
ErrorKind::ArgumentConflict if is_selector_conflict(error) => {
CliArgumentError::IncompatibleSelectors
}
ErrorKind::ArgumentConflict => CliArgumentError::DuplicateOption,
ErrorKind::TooManyValues => CliArgumentError::MultipleInputs,
ErrorKind::ValueValidation | ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--limit")
) =>
{
CliArgumentError::InvalidLimit
}
ErrorKind::ValueValidation | ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--depth")
) =>
{
CliArgumentError::InvalidDepth
}
ErrorKind::ValueValidation | ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--semantic")
) =>
{
CliArgumentError::InvalidSemanticId
}
ErrorKind::ValueValidation | ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--function")
) =>
{
CliArgumentError::InvalidFunctionId
}
ErrorKind::ValueValidation | ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--filter")
) =>
{
CliArgumentError::InvalidFilter
}
ErrorKind::InvalidValue
if matches!(
error.get(ContextKind::InvalidArg),
Some(ContextValue::String(argument)) if argument.starts_with("--sort")
) =>
{
CliArgumentError::InvalidSort
}
ErrorKind::UnknownArgument => match error.get(ContextKind::InvalidArg) {
Some(ContextValue::String(argument)) if !argument.starts_with('-') => {
if command == "inspect" && has_invalid_separated_function_value(&args[1..]) {
CliArgumentError::InvalidFunctionId
} else {
CliArgumentError::MultipleInputs
}
}
_ => CliArgumentError::UnknownOption,
},
_ => CliArgumentError::UnknownOption,
}
}
fn has_invalid_separated_function_value(args: &[OsString]) -> bool {
args.iter().enumerate().any(|(index, argument)| {
argument == "--function"
&& args
.get(index + 1)
.and_then(|value| value.to_str())
.is_none_or(|value| value.parse::<FunctionSelector>().is_err())
})
}
fn first_report_argument_error(args: &[OsString]) -> Option<CliArgumentError> {
let mut args = args.iter();
let mut has_input = false;
let mut has_output = false;
let mut has_artifact_output = false;
let mut has_no_clobber = false;
while let Some(argument) = args.next() {
if matches!(argument.to_str(), Some("-h" | "--help")) {
return None;
}
if argument == "--output" {
if has_output {
return Some(CliArgumentError::DuplicateOutput);
}
has_output = true;
if args.next().is_none() {
return Some(CliArgumentError::MissingOutputValue);
}
} else if argument == "--artifact-output" {
if has_artifact_output {
return Some(CliArgumentError::DuplicateArtifactOutput);
}
has_artifact_output = true;
if args.next().is_none() {
return Some(CliArgumentError::MissingArtifactOutputValue);
}
} else if argument == "--no-clobber" {
if has_no_clobber {
return Some(CliArgumentError::DuplicateOption);
}
has_no_clobber = true;
} else if argument.as_encoded_bytes().starts_with(b"-") {
return Some(CliArgumentError::UnknownOption);
} else if has_input {
return Some(CliArgumentError::MultipleInputs);
} else {
has_input = true;
}
}
(!has_input).then_some(CliArgumentError::MissingInput)
}
fn is_selector_conflict(error: &clap::Error) -> bool {
let arguments = [
error.get(ContextKind::InvalidArg),
error.get(ContextKind::PriorArg),
];
let contains_semantic = arguments.into_iter().flatten().any(|value| {
matches!(value, ContextValue::String(argument) if argument.starts_with("--semantic"))
});
let contains_function = arguments.into_iter().flatten().any(|value| {
matches!(value, ContextValue::String(argument) if argument.starts_with("--function"))
});
contains_semantic && contains_function
}
fn cli_failure(args: &[OsString], error: &clap::Error) -> RankedReportFailure {
let argument_error = classify_cli_error(args, error);
let message = clap_suggestion(error).map_or_else(
|| argument_error.to_string(),
|suggestion| format!("{argument_error}; did you mean {suggestion}?"),
);
RankedReportFailure::new(
RankedReportFailurePhase::Argument,
argument_error.kind(),
message,
)
}
fn clap_suggestion(error: &clap::Error) -> Option<String> {
[ContextKind::SuggestedArg, ContextKind::SuggestedSubcommand]
.into_iter()
.find_map(|kind| {
let suggestion = match error.get(kind)? {
ContextValue::String(suggestion) => suggestion.clone(),
ContextValue::Strings(suggestions) => suggestions.first()?.clone(),
_ => return None,
};
(suggestion.len() <= 64 && suggestion.is_ascii()).then_some(suggestion)
})
}
fn emit_failure(error: RankedReportFailure) -> i32 {
eprintln!("{}", error.machine_line());
failure_exit_code(error.phase())
}
fn failure_exit_code(phase: RankedReportFailurePhase) -> i32 {
match phase {
RankedReportFailurePhase::Argument => 64,
RankedReportFailurePhase::Health
| RankedReportFailurePhase::Query
| RankedReportFailurePhase::AggregateValidation => 65,
RankedReportFailurePhase::Input => 66,
RankedReportFailurePhase::TraceProcessor => 69,
RankedReportFailurePhase::Serialization => 70,
RankedReportFailurePhase::Output => 73,
}
}
pub(super) fn preflight_ranked_report_paths(
input: &Path,
output: &Path,
) -> Result<RankedReportPaths, RankedReportPathError> {
let current_dir = if input.is_relative() || output.is_relative() {
Some(std::env::current_dir().map_err(|error| {
if input.is_relative() {
RankedReportPathError::InputUnreadable { source: error }
} else {
RankedReportPathError::OutputInspection { source: error }
}
})?)
} else {
None
};
let input = preflight_ranked_profile_input(&absolute_path_from(input, current_dir.as_deref()))?;
let output = absolute_path_from(output, current_dir.as_deref());
if output.file_name().is_none() {
return Err(RankedReportPathError::OutputHasNoFileName);
}
inspect_output_path(&output)?;
match super::output_paths_alias(&input, &output) {
Ok(true) => return Err(RankedReportPathError::InputOutputAlias),
Ok(false) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
return Err(RankedReportPathError::OutputInspection { source: error });
}
}
Ok(RankedReportPaths { input, output })
}
pub(super) fn preflight_ranked_profile_input(
input: &Path,
) -> Result<PathBuf, RankedReportPathError> {
let input = absolute_path(input).context(InputUnreadableSnafu)?;
let input_file = super::open_profile_input(&input).context(InputUnreadableSnafu)?;
if !input_file
.metadata()
.context(InputUnreadableSnafu)?
.is_file()
{
return Err(RankedReportPathError::InputNotFile);
}
let input = input.canonicalize().context(InputUnreadableSnafu)?;
Ok(input)
}
fn absolute_path(path: &Path) -> io::Result<PathBuf> {
let current_dir = path.is_relative().then(std::env::current_dir).transpose()?;
Ok(absolute_path_from(path, current_dir.as_deref()))
}
fn absolute_path_from(path: &Path, current_dir: Option<&Path>) -> PathBuf {
current_dir.map_or_else(|| path.to_owned(), |current_dir| current_dir.join(path))
}
fn inspect_output_path(output: &Path) -> Result<(), RankedReportPathError> {
match fs::metadata(output) {
Ok(metadata) if !metadata.is_file() => return Err(RankedReportPathError::OutputNotFile),
Ok(_) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) if error.kind() == io::ErrorKind::NotADirectory => {
return Err(RankedReportPathError::OutputParentNotDirectory);
}
Err(error) => {
return Err(RankedReportPathError::OutputInspection { source: error });
}
}
let mut ancestor = output
.parent()
.ok_or(RankedReportPathError::OutputHasNoFileName)?;
loop {
match fs::metadata(ancestor) {
Ok(metadata) if metadata.is_dir() => return Ok(()),
Ok(_) => return Err(RankedReportPathError::OutputParentNotDirectory),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
ancestor = ancestor
.parent()
.ok_or(RankedReportPathError::OutputParentNotDirectory)?;
}
Err(error) if error.kind() == io::ErrorKind::NotADirectory => {
return Err(RankedReportPathError::OutputParentNotDirectory);
}
Err(error) => {
return Err(RankedReportPathError::OutputInspection { source: error });
}
}
}
}
fn default_report_path(input: &Path) -> PathBuf {
input.with_extension("profile.html")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn interactive_document() -> super::super::ranked_report::RankedProfileDocument {
use super::super::ranked_report::{
RankedFunction, RankedProfileDocument, RankedProfileMetadata,
};
RankedProfileDocument {
metadata: RankedProfileMetadata {
capture_complete: true,
semantic_complete: true,
finalization_observed: true,
incomplete_operation_root_count: 0,
truncation_marker_count: 0,
missing_identity_field_count: 0,
missing_terminal_result_count: 0,
crossing_worker_slice_count: 0,
crossing_planning_activity_slice_count: 0,
crossing_execution_activity_slice_count: 0,
invalid_planning_activity_hierarchy_count: 0,
invalid_execution_activity_hierarchy_count: 0,
perf_sample_without_callsite_count: 0,
perf_samples_skipped: 0,
buffer_loss_count: 0,
data_source_loss_count: 0,
flush_failure_count: 0,
schema_version: 3,
sample_frequency_hz: 100,
sampled_cpu_count: 1,
exact_time_unit: "nanoseconds".to_owned(),
sample_unit: "samples".to_owned(),
eligible_sample_count: 0,
direct_sample_count: 0,
ambiguous_sample_count: 0,
unattributed_sample_count: 0,
resolved_function_sample_count: 0,
unresolved_function_sample_count: 0,
unwind_error_sample_count: 0,
missing_callstack_sample_count: 0,
trace_profiler_dropped_sample_count: 0,
},
semantics: vec![
interactive_semantic(1, None, "operation"),
interactive_semantic(2, Some(1), "planning"),
interactive_semantic(3, Some(2), "metadata"),
],
functions: vec![
RankedFunction {
semantic_id: 1,
function_id: 10,
parent_function_id: None,
name: "root_function".to_owned(),
module_name: None,
source_file: None,
line_number: None,
self_sample_count: 1,
inclusive_sample_count: 2,
},
RankedFunction {
semantic_id: 1,
function_id: 11,
parent_function_id: Some(10),
name: "child_function".to_owned(),
module_name: None,
source_file: None,
line_number: None,
self_sample_count: 1,
inclusive_sample_count: 1,
},
RankedFunction {
semantic_id: 2,
function_id: 20,
parent_function_id: None,
name: "other_semantic_function".to_owned(),
module_name: None,
source_file: None,
line_number: None,
self_sample_count: 1,
inclusive_sample_count: 1,
},
],
}
}
fn interactive_semantic(
semantic_id: i64,
parent_semantic_id: Option<i64>,
name: &str,
) -> super::super::ranked_report::RankedSemantic {
super::super::ranked_report::RankedSemantic {
semantic_id,
parent_semantic_id,
operation_id: 1,
name: name.to_owned(),
semantic_kind: name.to_owned(),
operation_kind: Some("preview".to_owned()),
stage_category: None,
stage_name: None,
activity: None,
start_ns: 0,
end_ns: Some(10),
duration_ns: Some(10),
time_semantics: "wall_clock".to_owned(),
result: Some("completed".to_owned()),
is_complete: true,
query_execution_id: None,
query_scope: None,
query_owner: None,
worker_lane_id: None,
worker_kind: None,
node_id: None,
parent_node_id: None,
operator_partition: None,
execution_stream_id: None,
stage_owner_id: None,
direct_sample_count: 2,
inclusive_sample_count: 4,
resolved_function_sample_count: 2,
unresolved_function_sample_count: 0,
unwind_error_sample_count: 0,
missing_callstack_sample_count: 0,
}
}
fn interactive_output(
result: Result<InteractiveCommandResult, &'static str>,
) -> Result<String, &'static str> {
match result? {
InteractiveCommandResult::Output(output) => Ok(output),
InteractiveCommandResult::Quit => Err("interactive command exited unexpectedly"),
}
}
#[test]
fn parses_report_arguments_and_generates_help() -> Result<(), Box<dyn std::error::Error>> {
let root_help = PerfettoCli::try_parse_from(["delta-funnel-perfetto", "--help"])
.err()
.ok_or("root help should stop parsing")?;
assert_eq!(root_help.kind(), ErrorKind::DisplayHelp);
let root_help = root_help.to_string();
assert!(root_help.contains("Generate and inspect Delta Funnel Perfetto diagnostics"));
assert!(root_help.contains("report"));
assert!(root_help.contains("inspect"));
let bare_root_help = PerfettoCli::try_parse_from(["delta-funnel-perfetto", "help"])
.err()
.ok_or("bare root help should stop parsing")?;
assert_eq!(bare_root_help.kind(), ErrorKind::DisplayHelp);
let report_help =
PerfettoCli::try_parse_from(["delta-funnel-perfetto", "report", "--help"])
.err()
.ok_or("report help should stop parsing")?;
assert_eq!(report_help.kind(), ErrorKind::DisplayHelp);
let report_help = report_help.to_string();
assert!(report_help.contains("<INPUT>"));
assert!(report_help.contains("Perfetto trace or ranked profile artifact"));
assert!(report_help.contains("--output <OUTPUT.profile.html>"));
assert!(report_help.contains("--artifact-output <OUTPUT.dfprofile>"));
assert!(report_help.contains("--no-clobber"));
let bare_report_help =
PerfettoCli::try_parse_from(["delta-funnel-perfetto", "help", "report"])
.err()
.ok_or("bare report help should stop parsing")?;
assert_eq!(bare_report_help.kind(), ErrorKind::DisplayHelp);
let default_output =
PerfettoCli::try_parse_from(["delta-funnel-perfetto", "report", "capture.pftrace"])?;
assert_eq!(
default_output,
PerfettoCli {
command: PerfettoCommand::Report(RankedReportArgs {
input: PathBuf::from("capture.pftrace"),
output: None,
artifact_output: None,
no_clobber: false,
}),
}
);
let explicit_output = PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"report",
"--output",
"reports/capture.html",
"--artifact-output",
"reports/capture.dfprofile",
"traces/capture",
])?;
assert_eq!(
explicit_output,
PerfettoCli {
command: PerfettoCommand::Report(RankedReportArgs {
input: PathBuf::from("traces/capture"),
output: Some(PathBuf::from("reports/capture.html")),
artifact_output: Some(PathBuf::from("reports/capture.dfprofile")),
no_clobber: false,
}),
}
);
let no_clobber = PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"report",
"--no-clobber",
"capture.pftrace",
])?;
assert_eq!(
no_clobber,
PerfettoCli {
command: PerfettoCommand::Report(RankedReportArgs {
input: PathBuf::from("capture.pftrace"),
output: None,
artifact_output: None,
no_clobber: true,
}),
}
);
assert_eq!(
default_report_path(Path::new("capture.pftrace")),
PathBuf::from("capture.profile.html")
);
Ok(())
}
#[test]
fn parses_inspect_arguments_and_generates_help() -> Result<(), Box<dyn std::error::Error>> {
let help = PerfettoCli::try_parse_from(["delta-funnel-perfetto", "inspect", "--help"])
.err()
.ok_or("inspect help should stop parsing")?;
assert_eq!(help.kind(), ErrorKind::DisplayHelp);
let help = help.to_string();
assert!(help.contains("<INPUT.dfprofile>"));
assert!(help.contains("Ranked profile artifact to inspect"));
assert!(help.contains("--limit <LIMIT>"));
assert!(help.contains("--semantic <ID>"));
assert!(help.contains("--function <SEMANTIC_ID:FUNCTION_ID>"));
assert!(help.contains("--filter <TEXT>"));
assert!(help.contains("--interactive"));
assert!(help.contains("--full-symbols"));
assert!(help.contains("--all-frames"));
assert!(help.contains("--sort <SORT>"));
assert!(help.contains("--depth <DEPTH>"));
let bare_help = PerfettoCli::try_parse_from(["delta-funnel-perfetto", "help", "inspect"])
.err()
.ok_or("bare inspect help should stop parsing")?;
assert_eq!(bare_help.kind(), ErrorKind::DisplayHelp);
assert_eq!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.dfprofile",
"--limit",
"7",
"--semantic",
"42",
"--sort",
"self-cpu",
"--filter",
"scan",
"--depth",
"3",
"--interactive",
"--full-symbols",
"--all-frames",
])?,
PerfettoCli {
command: PerfettoCommand::Inspect(InspectArgs {
input: PathBuf::from("capture.dfprofile"),
limit: 7,
semantic: Some(42),
function: None,
filter: Some(FilterText("scan".to_owned())),
sort: Some(InspectSort::SelfCpu),
depth: Some(3),
interactive: true,
full_symbols: true,
all_frames: true,
}),
}
);
assert_eq!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.dfprofile",
"--function",
"42:7",
])?,
PerfettoCli {
command: PerfettoCommand::Inspect(InspectArgs {
input: PathBuf::from("capture.dfprofile"),
limit: DEFAULT_INSPECT_LIMIT,
semantic: None,
function: Some(FunctionSelector {
semantic_id: 42,
function_id: 7,
}),
filter: None,
sort: None,
depth: None,
interactive: false,
full_symbols: false,
all_frames: false,
}),
}
);
assert!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.pftrace",
"--semantic",
"-1",
])
.is_ok()
);
assert!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.pftrace",
"--function=-1:-2",
])
.is_ok()
);
assert!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.pftrace",
"--function",
"-1:-2",
"--limit",
"2",
])
.is_ok()
);
assert!(
PerfettoCli::try_parse_from([
"delta-funnel-perfetto",
"inspect",
"capture.pftrace",
"--filter=--scan",
])
.is_ok()
);
Ok(())
}
#[test]
fn interactive_session_is_line_oriented_bounded_and_recoverable() {
let document = interactive_document();
let mut state = InspectState {
selection: InspectSelection::Root,
sort: InspectSort::Duration,
filter: None,
limit: 20,
depth: None,
full_symbols: false,
all_frames: false,
};
let mut commands = vec![b'a'; MAX_INTERACTIVE_COMMAND_BYTES + 1];
commands.extend_from_slice(b"\nshow\nunknown\nhelp\nquit\n");
let mut input = io::Cursor::new(commands);
let mut output = Vec::new();
let mut error = Vec::new();
run_interactive_session(
&document,
&mut state,
&mut input,
&mut output,
&mut error,
false,
)
.expect("recoverable commands should preserve the session");
let output = String::from_utf8(output).expect("output should be UTF-8");
let error = String::from_utf8(error).expect("errors should be UTF-8");
assert_eq!(output.matches(INTERACTIVE_END_MARKER).count(), 6);
assert_eq!(output.matches("context: operation-roots").count(), 2);
assert!(output.contains("open semantic:ID"));
assert!(!output.contains("profile> "));
assert!(error.contains("interactive command exceeds the 1024-byte limit"));
assert!(error.contains("unknown interactive command"));
}
#[test]
fn interactive_session_recovers_from_invalid_utf8_and_exits_on_eof() {
let document = interactive_document();
let mut state = InspectState {
selection: InspectSelection::Root,
sort: InspectSort::Duration,
filter: None,
limit: 20,
depth: None,
full_symbols: false,
all_frames: false,
};
let mut input = io::Cursor::new([0xff, b'\n']);
let mut output = Vec::new();
let mut error = Vec::new();
run_interactive_session(
&document,
&mut state,
&mut input,
&mut output,
&mut error,
true,
)
.expect("EOF should close a healthy session");
let output = String::from_utf8(output).expect("output should be UTF-8");
let error = String::from_utf8(error).expect("errors should be UTF-8");
assert_eq!(output.matches(INTERACTIVE_END_MARKER).count(), 2);
assert_eq!(output.matches("profile> ").count(), 2);
assert!(error.contains("interactive command must be valid UTF-8"));
}
#[test]
fn interactive_navigation_requires_exact_immediate_children() {
let document = interactive_document();
let mut state = InspectState {
selection: InspectSelection::Root,
sort: InspectSort::Duration,
filter: None,
limit: 20,
depth: None,
full_symbols: false,
all_frames: false,
};
let commands = b"open semantic:2\nopen semantic:1\nopen function:1:11\nopen function:2:20\nopen function:1:10\nopen function:1:11\nup\nup\nopen semantic:2\nroot\nup\nquit\n";
let mut input = io::Cursor::new(commands);
let mut output = Vec::new();
let mut error = Vec::new();
run_interactive_session(
&document,
&mut state,
&mut input,
&mut output,
&mut error,
false,
)
.expect("navigation errors should preserve the session");
let output = String::from_utf8(output).expect("output should be UTF-8");
let error = String::from_utf8(error).expect("errors should be UTF-8");
assert_eq!(output.matches(INTERACTIVE_END_MARKER).count(), 13);
assert_eq!(output.matches("context: operation-roots").count(), 2);
assert_eq!(output.matches("context: semantic:1").count(), 2);
assert_eq!(output.matches("context: function:1:10").count(), 2);
assert_eq!(output.matches("context: function:1:11").count(), 1);
assert_eq!(output.matches("context: semantic:2").count(), 1);
assert_eq!(
error
.matches("semantic target is not an immediate child")
.count(),
1
);
assert_eq!(
error
.matches("function target is not an immediate child")
.count(),
2
);
assert!(error.contains("already at operation roots"));
}
#[test]
fn interactive_sort_and_limit_validate_before_changing_state() -> Result<(), &'static str> {
let document = interactive_document();
let index = TerminalProfileIndex::new(&document, false, false);
let mut state = InspectState {
selection: InspectSelection::Root,
sort: InspectSort::Duration,
filter: None,
limit: 20,
depth: None,
full_symbols: false,
all_frames: false,
};
assert!(matches!(
run_interactive_command(&index, &mut state, "open semantic:1"),
Ok(InteractiveCommandResult::Output(_))
));
let output = interactive_output(run_interactive_command(&index, &mut state, "limit 1"))?;
assert_eq!(state.limit, 1);
assert!(output.contains("showing: 1 of 2; truncated: true"));
assert!(output.contains("id=function:1:10"));
assert!(matches!(
run_interactive_command(&index, &mut state, "limit 0"),
Err("limit must be between 1 and 200")
));
assert_eq!(state.limit, 1);
let output = interactive_output(run_interactive_command(&index, &mut state, "sort name"))?;
assert_eq!(state.sort, InspectSort::Name);
assert!(output.contains("sort: name"));
assert!(matches!(
run_interactive_command(&index, &mut state, "sort unknown"),
Err("invalid sort; expected duration, inclusive-cpu, self-cpu, or name")
));
assert_eq!(state.sort, InspectSort::Name);
assert!(matches!(
run_interactive_command(&index, &mut state, "open function:1:10"),
Ok(InteractiveCommandResult::Output(_))
));
assert!(matches!(
run_interactive_command(&index, &mut state, "sort duration"),
Err("function callsites cannot be sorted by exact duration")
));
assert_eq!(state.sort, InspectSort::Name);
Ok(())
}
#[test]
fn interactive_filter_is_bounded_and_clearable() -> Result<(), &'static str> {
let document = interactive_document();
let index = TerminalProfileIndex::new(&document, false, false);
let mut state = InspectState {
selection: InspectSelection::Semantic(1),
sort: InspectSort::Duration,
filter: None,
limit: 20,
depth: None,
full_symbols: false,
all_frames: false,
};
let output = interactive_output(run_interactive_command(
&index,
&mut state,
"filter planning",
))?;
assert_eq!(state.filter.as_deref(), Some("planning"));
assert!(output.contains("filter: \"planning\""));
assert!(output.contains("id=semantic:2"));
let oversized = format!("filter {}", "x".repeat(MAX_FILTER_CHARS + 1));
assert!(matches!(
run_interactive_command(&index, &mut state, &oversized),
Err("filter must contain between 1 and 128 characters")
));
assert_eq!(state.filter.as_deref(), Some("planning"));
assert!(matches!(
run_interactive_command(&index, &mut state, "filter"),
Err("filter must contain between 1 and 128 characters")
));
assert_eq!(state.filter.as_deref(), Some("planning"));
let output = interactive_output(run_interactive_command(&index, &mut state, "clear"))?;
assert_eq!(state.filter, None);
assert!(output.contains("filter: none"));
Ok(())
}
#[test]
fn dispatches_commands_and_maps_failure_phases_to_exit_codes() {
assert_eq!(
run_perfetto_diagnostics_cli_with_args([OsString::from("--help")]),
0
);
assert_eq!(
run_perfetto_diagnostics_cli_with_args([
OsString::from("report"),
OsString::from("--help"),
]),
0
);
assert_eq!(
run_perfetto_diagnostics_cli_with_args([
OsString::from("inspect"),
OsString::from("--help")
]),
0
);
assert_eq!(
run_inspect_command(InspectArgs {
input: PathBuf::from("missing.pftrace"),
limit: DEFAULT_INSPECT_LIMIT,
semantic: None,
function: Some(FunctionSelector {
semantic_id: 1,
function_id: 2,
}),
filter: None,
sort: Some(InspectSort::Duration),
depth: None,
interactive: false,
full_symbols: false,
all_frames: false,
}),
64
);
assert_eq!(
run_perfetto_diagnostics_cli_with_args([OsString::from("unknown")]),
64
);
assert_eq!(failure_exit_code(RankedReportFailurePhase::Health), 65);
assert_eq!(failure_exit_code(RankedReportFailurePhase::Input), 66);
assert_eq!(
failure_exit_code(RankedReportFailurePhase::TraceProcessor),
69
);
assert_eq!(
failure_exit_code(RankedReportFailurePhase::Serialization),
70
);
assert_eq!(failure_exit_code(RankedReportFailurePhase::Output), 73);
}
#[test]
fn rejects_invalid_argument_shapes_with_stable_kinds() -> Result<(), Box<dyn std::error::Error>>
{
for (args, expected) in [
(vec![], CliArgumentError::MissingCommand),
(
vec![OsString::from("unknown")],
CliArgumentError::UnknownCommand,
),
(
vec![OsString::from("report")],
CliArgumentError::MissingInput,
),
(
vec![OsString::from("report"), OsString::from("--output")],
CliArgumentError::MissingOutputValue,
),
(
vec![
OsString::from("report"),
OsString::from("--artifact-output"),
],
CliArgumentError::MissingArtifactOutputValue,
),
(
vec![
OsString::from("report"),
OsString::from("trace.pftrace"),
OsString::from("--output"),
OsString::from("first.html"),
OsString::from("--output"),
],
CliArgumentError::DuplicateOutput,
),
(
vec![
OsString::from("report"),
OsString::from("trace.pftrace"),
OsString::from("--artifact-output"),
OsString::from("first.dfprofile"),
OsString::from("--artifact-output"),
],
CliArgumentError::DuplicateArtifactOutput,
),
(
vec![
OsString::from("report"),
OsString::from("--output"),
OsString::from("first.html"),
OsString::from("--output"),
OsString::from("second.html"),
OsString::from("--unknown"),
],
CliArgumentError::DuplicateOutput,
),
(
vec![
OsString::from("report"),
OsString::from("first.pftrace"),
OsString::from("second.pftrace"),
OsString::from("--output"),
],
CliArgumentError::MultipleInputs,
),
(
vec![
OsString::from("report"),
OsString::from("first.pftrace"),
OsString::from("second.pftrace"),
OsString::from("--help"),
],
CliArgumentError::MultipleInputs,
),
(
vec![
OsString::from("report"),
OsString::from("--unknown"),
OsString::from("--output"),
],
CliArgumentError::UnknownOption,
),
(
vec![
OsString::from("report"),
OsString::from("trace.pftrace"),
OsString::from("--output"),
OsString::from("first.html"),
OsString::from("--output"),
OsString::from("second.html"),
],
CliArgumentError::DuplicateOutput,
),
(
vec![
OsString::from("report"),
OsString::from("first.pftrace"),
OsString::from("second.pftrace"),
],
CliArgumentError::MultipleInputs,
),
(
vec![OsString::from("report"), OsString::from("--unknown")],
CliArgumentError::UnknownOption,
),
(
vec![
OsString::from("report"),
OsString::from("capture.pftrace"),
OsString::from("--no-clobber"),
OsString::from("--no-clobber"),
],
CliArgumentError::DuplicateOption,
),
(
vec![OsString::from("inspect")],
CliArgumentError::MissingInput,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--limit"),
],
CliArgumentError::InvalidLimit,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--depth"),
],
CliArgumentError::InvalidDepth,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--semantic"),
],
CliArgumentError::InvalidSemanticId,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--function"),
],
CliArgumentError::InvalidFunctionId,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--function"),
OsString::from("--limit"),
OsString::from("2"),
],
CliArgumentError::InvalidFunctionId,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--filter"),
],
CliArgumentError::InvalidFilter,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--filter"),
OsString::from("--limit"),
OsString::from("2"),
],
CliArgumentError::InvalidFilter,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--sort"),
],
CliArgumentError::InvalidSort,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--limit"),
OsString::from("0"),
],
CliArgumentError::InvalidLimit,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--limit"),
OsString::from("-1"),
],
CliArgumentError::InvalidLimit,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--depth"),
OsString::from("33"),
],
CliArgumentError::InvalidDepth,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--depth"),
OsString::from("-1"),
],
CliArgumentError::InvalidDepth,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--semantic"),
OsString::from("worker-1"),
],
CliArgumentError::InvalidSemanticId,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--function"),
OsString::from("42"),
],
CliArgumentError::InvalidFunctionId,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--filter"),
OsString::new(),
],
CliArgumentError::InvalidFilter,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--sort"),
OsString::from("cpu"),
],
CliArgumentError::InvalidSort,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--semantic"),
OsString::from("42"),
OsString::from("--function"),
OsString::from("42:7"),
],
CliArgumentError::IncompatibleSelectors,
),
(
vec![
OsString::from("inspect"),
OsString::from("capture.pftrace"),
OsString::from("--limit"),
OsString::from("10"),
OsString::from("--limit"),
OsString::from("20"),
],
CliArgumentError::DuplicateOption,
),
] {
let error = PerfettoCli::try_parse_from(
iter::once(OsString::from("delta-funnel-perfetto")).chain(args.iter().cloned()),
)
.err()
.ok_or("invalid arguments should fail")?;
assert_eq!(classify_cli_error(&args, &error), expected);
}
Ok(())
}
#[test]
fn failures_expose_stable_machine_readable_fields() -> Result<(), Box<dyn std::error::Error>> {
let argument_failure = RankedReportFailure::from(CliArgumentError::MissingInput);
assert_eq!(argument_failure.phase(), RankedReportFailurePhase::Argument);
assert_eq!(argument_failure.kind(), "missing_input");
assert_eq!(
argument_failure.to_string(),
"a profile input path is required"
);
let failure = RankedReportFailure::new(
RankedReportFailurePhase::TraceProcessor,
"execution_failed",
"first line\nsecond line",
);
let value: serde_json::Value =
serde_json::from_str(&failure.machine_line()).expect("failure should be valid JSON");
assert_eq!(value["phase"], "trace_processor");
assert_eq!(value["kind"], "execution_failed");
assert_eq!(value["message"], "first line\nsecond line");
let path_failure = RankedReportFailure::from(RankedReportPathError::InputOutputAlias);
assert_eq!(path_failure.phase(), RankedReportFailurePhase::Output);
assert_eq!(path_failure.kind(), "aliases_input");
let path_error = RankedReportPathError::InputUnreadable {
source: io::Error::new(io::ErrorKind::PermissionDenied, "access denied"),
};
let source = std::error::Error::source(&path_error)
.and_then(|source| source.downcast_ref::<io::Error>())
.ok_or("path error should retain its I/O source")?;
assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
let input_failure = RankedReportFailure::from(path_error);
assert_eq!(input_failure.phase(), RankedReportFailurePhase::Input);
assert_eq!(input_failure.kind(), "unreadable");
assert_eq!(
input_failure.to_string(),
"profile input is not readable: access denied"
);
let typo = vec![OsString::from("reprot")];
let typo_error = PerfettoCli::try_parse_from(["delta-funnel-perfetto", "reprot"])
.err()
.ok_or("misspelled command should fail")?;
let typo_failure = cli_failure(&typo, &typo_error);
assert_eq!(typo_failure.kind(), "unknown_command");
assert!(typo_failure.to_string().contains("did you mean report?"));
Ok(())
}
#[test]
fn preflights_readable_input_and_non_mutating_output_paths() -> io::Result<()> {
let directory = tempfile::tempdir()?;
let input = directory.path().join("capture.pftrace");
File::create(&input)?.write_all(b"trace")?;
let missing_parent = directory.path().join("reports/nested");
let output = missing_parent.join("capture.profile.html");
let paths = preflight_ranked_report_paths(&input, &output)
.map_err(|error| io::Error::other(error.to_string()))?;
assert_eq!(paths.input, input.canonicalize()?);
assert_eq!(paths.output, output);
assert!(!missing_parent.exists());
Ok(())
}
#[test]
fn resolves_relative_report_paths_against_one_directory() -> io::Result<()> {
let current_dir = std::env::current_dir()?;
let directory = tempfile::tempdir_in(¤t_dir)?;
let relative_directory = directory
.path()
.strip_prefix(¤t_dir)
.expect("temporary directory should be under the current directory");
let input = relative_directory.join("capture.pftrace");
File::create(current_dir.join(&input))?.write_all(b"trace")?;
let output = relative_directory.join("capture.profile.html");
let paths = preflight_ranked_report_paths(&input, &output)
.map_err(|error| io::Error::other(error.to_string()))?;
assert_eq!(paths.input, current_dir.join(&input).canonicalize()?);
assert_eq!(paths.output, current_dir.join(output));
Ok(())
}
#[test]
fn rejects_non_files_and_every_existing_input_alias() -> io::Result<()> {
let directory = tempfile::tempdir()?;
let input = directory.path().join("capture.pftrace");
File::create(&input)?.write_all(b"trace")?;
assert!(matches!(
preflight_ranked_report_paths(directory.path(), &directory.path().join("report.html")),
Err(RankedReportPathError::InputNotFile)
));
assert!(matches!(
preflight_ranked_report_paths(&input, &input),
Err(RankedReportPathError::InputOutputAlias)
));
let hard_link = directory.path().join("hard-link.pftrace");
fs::hard_link(&input, &hard_link)?;
assert!(matches!(
preflight_ranked_report_paths(&input, &hard_link),
Err(RankedReportPathError::InputOutputAlias)
));
Ok(())
}
#[cfg(unix)]
#[test]
fn preserves_symlink_parent_semantics_when_detecting_aliases() -> io::Result<()> {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir()?;
let real_parent = directory.path().join("real/child");
fs::create_dir_all(&real_parent)?;
let input = directory.path().join("real/capture.pftrace");
File::create(&input)?.write_all(b"trace")?;
let link = directory.path().join("link");
symlink(&real_parent, &link)?;
let output = link.join("../capture.pftrace");
assert!(matches!(
preflight_ranked_report_paths(&input, &output),
Err(RankedReportPathError::InputOutputAlias)
));
Ok(())
}
#[test]
fn rejects_input_alias_through_a_missing_parent_and_dot_dot() -> io::Result<()> {
let directory = tempfile::tempdir()?;
let input = directory.path().join("capture.pftrace");
File::create(&input)?.write_all(b"trace")?;
let missing_parent = directory.path().join("missing");
let output = missing_parent.join("../capture.pftrace");
assert!(matches!(
preflight_ranked_report_paths(&input, &output),
Err(RankedReportPathError::InputOutputAlias)
));
assert_eq!(fs::read(&input)?, b"trace");
assert!(!missing_parent.exists());
Ok(())
}
#[test]
fn rejects_invalid_output_shapes() -> io::Result<()> {
let directory = tempfile::tempdir()?;
let input = directory.path().join("capture.pftrace");
File::create(&input)?.write_all(b"trace")?;
assert!(matches!(
preflight_ranked_report_paths(&input, Path::new(std::path::MAIN_SEPARATOR_STR)),
Err(RankedReportPathError::OutputHasNoFileName)
));
assert!(matches!(
preflight_ranked_report_paths(&input, directory.path()),
Err(RankedReportPathError::OutputHasNoFileName)
| Err(RankedReportPathError::OutputNotFile)
));
let parent_file = directory.path().join("parent-file");
File::create(&parent_file)?;
assert!(matches!(
preflight_ranked_report_paths(&input, &parent_file.join("report.html")),
Err(RankedReportPathError::OutputParentNotDirectory)
));
Ok(())
}
}