Skip to main content

candle_graph/cli/
args.rs

1//! Single shared Clap definition for `candle-graph` and `cargo candle-graph`.
2//!
3//! Both binaries parse the same [`Cli`]; the cargo wrapper only strips the
4//! forwarded subcommand name and rebrands the top-level command, so there are
5//! zero duplicated subcommand definitions.
6
7use std::ffi::OsString;
8use std::path::PathBuf;
9
10use anyhow::Result;
11use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
12
13use super::trace_cli::{self, QueryLabelFilter, TraceQueryKind};
14
15/// Complete typed CLI protocol shared by both binary entrypoints.
16#[derive(Parser, Debug)]
17#[command(
18    name = "candle-graph",
19    version,
20    about = "Import and visualize Candle execution traces",
21    long_about = "Capability-qualified evidence and atomic bundles from candle-graph/trace/10 runs."
22)]
23pub struct Cli {
24    #[command(subcommand)]
25    command: Command,
26}
27
28impl Cli {
29    /// Dispatch the parsed command into the evidence CLI engine.
30    pub fn run(self) -> Result<()> {
31        match self.command {
32            Command::Import(import) => {
33                trace_cli::run_import(&import.trace, import.output.as_deref())
34            }
35            #[cfg(feature = "visualizer")]
36            Command::View(view) => {
37                trace_cli::run_view(&view.trace, &view.output, view.nsight_dir.as_deref())
38            }
39            Command::Summary(summary) => trace_cli::run_summary(
40                &summary.input,
41                summary.output.as_deref(),
42                summary.require_valid,
43            ),
44            Command::Query(query) => {
45                let filter = query.filter();
46                trace_cli::run_query(
47                    &query.input,
48                    query.kind.into(),
49                    filter.as_ref(),
50                    query.output.as_deref(),
51                )
52            }
53            Command::Overview(overview) => {
54                trace_cli::run_overview(&overview.input, overview.output.as_deref())
55            }
56            Command::Compare(compare) => trace_cli::run_compare(
57                &compare.baseline,
58                &compare.candidate,
59                compare.unverified_traces,
60                compare.require_eligible,
61                compare.output.as_deref(),
62            ),
63            Command::Report(report) => trace_cli::run_report(
64                &report.trace,
65                report.nsight_dir.as_deref(),
66                &report.bundle,
67                report.output.as_deref(),
68            ),
69            Command::Verify(verify) => {
70                trace_cli::run_verify(&verify.bundle, verify.semantic, verify.output.as_deref())
71            }
72            Command::Protocol(protocol) => trace_cli::run_protocol(protocol.output.as_deref()),
73            Command::CampaignStatus(status) => {
74                trace_cli::run_campaign_status(&status.manifest, status.output.as_deref())
75            }
76            Command::Series(series) => trace_cli::run_series(
77                series.manifest.as_deref(),
78                &series.bundle,
79                series.label_prefix.as_deref(),
80                series.output.as_deref(),
81            ),
82        }
83    }
84
85    /// Parse argv for the `cargo-candle-graph` wrapper.
86    ///
87    /// Cargo forwards the external subcommand name through argv
88    /// (`cargo candle-graph summary …` invokes
89    /// `cargo-candle-graph candle-graph summary …`), so a leading
90    /// `candle-graph` element is stripped before parsing. The top-level
91    /// name/about are rebranded on the same shared [`Cli`] definition.
92    pub fn parse_as_cargo_subcommand() -> Self {
93        let mut argv: Vec<OsString> = std::env::args_os().collect();
94        if argv
95            .get(1)
96            .is_some_and(|argument| argument == "candle-graph")
97        {
98            argv.remove(1);
99        }
100        let command = Self::command()
101            .name("cargo-candle-graph")
102            .bin_name("cargo candle-graph")
103            .about("Import and analyze candle-graph execution trace files");
104        let matches = command.get_matches_from(argv);
105        match Self::from_arg_matches(&matches) {
106            Ok(cli) => cli,
107            Err(error) => error.exit(),
108        }
109    }
110}
111
112/// Bounded machine-readable catalog of every subcommand, for `protocol`.
113pub fn command_catalog() -> Vec<serde_json::Value> {
114    Cli::command()
115        .get_subcommands()
116        .map(|subcommand| {
117            serde_json::json!({
118                "name": subcommand.get_name(),
119                "about": subcommand.get_about().map(|about| about.to_string()),
120            })
121        })
122        .collect()
123}
124
125#[derive(Subcommand, Debug)]
126enum Command {
127    /// Emit a complete capability-qualified evidence packet.
128    Import(ImportArgs),
129    /// Render a standalone HTML visualizer from a trace or verified bundle (requires `visualizer` feature).
130    #[cfg(feature = "visualizer")]
131    View(ViewArgs),
132    /// Emit a profiler summary for a raw trace or verified bundle/profile.
133    Summary(SummaryArgs),
134    /// Run a typed query against raw-trace or verified bundle evidence.
135    Query(QueryArgs),
136    /// Emit a bounded first-look overview of a raw trace or verified bundle.
137    Overview(OverviewArgs),
138    /// Compare a candidate profile run with an explicit baseline.
139    Compare(CompareArgs),
140    /// Atomically publish a content-addressed evidence bundle and emit its publication receipt.
141    Report(ReportArgs),
142    /// Deeply verify a published evidence bundle and emit a durable receipt.
143    Verify(VerifyArgs),
144    /// Emit the versioned schema and command protocol of this tool.
145    Protocol(ProtocolArgs),
146    /// Reconcile a campaign manifest against published bundles on disk.
147    CampaignStatus(CampaignStatusArgs),
148    /// Build a cross-run series report from a campaign manifest or explicit bundles.
149    Series(SeriesArgs),
150}
151
152#[derive(Args, Debug)]
153struct ImportArgs {
154    /// Raw trace, finalized bundle/profile directory, or its `trace.jsonl`.
155    #[arg(value_name = "INPUT")]
156    trace: PathBuf,
157    #[arg(long, short, value_name = "FILE")]
158    output: Option<PathBuf>,
159}
160
161#[cfg(feature = "visualizer")]
162#[derive(Args, Debug)]
163struct ViewArgs {
164    /// Trace JSONL file (`candle-graph/trace/10` or readable trace/9), or a verified bundle.
165    #[arg(value_name = "TRACE")]
166    trace: PathBuf,
167    #[arg(long, value_name = "FILE")]
168    output: PathBuf,
169    /// Nsight artifact directory; rejected for bundle inputs, which already bind their Nsight evidence.
170    #[arg(long, value_name = "DIR")]
171    nsight_dir: Option<PathBuf>,
172}
173
174#[derive(Args, Debug)]
175struct SummaryArgs {
176    /// Raw trace, finalized bundle/profile directory, or its `trace.jsonl`.
177    #[arg(value_name = "INPUT")]
178    input: PathBuf,
179    /// Exit nonzero (after writing output) unless the capture is structurally valid and complete.
180    #[arg(long)]
181    require_valid: bool,
182    #[arg(long, short, value_name = "FILE")]
183    output: Option<PathBuf>,
184}
185
186#[derive(Args, Debug)]
187struct QueryArgs {
188    /// Raw trace, finalized bundle/profile directory, or its `trace.jsonl`.
189    #[arg(value_name = "INPUT")]
190    input: PathBuf,
191    #[arg(long, value_enum)]
192    kind: CliTraceQueryKind,
193    /// Exact label filter (kinds: spans, tensors, tensor-stats, gradients).
194    #[arg(long, value_name = "S", conflicts_with = "label_prefix")]
195    label: Option<String>,
196    /// Label-prefix filter (kinds: spans, tensors, tensor-stats, gradients).
197    #[arg(long, value_name = "S")]
198    label_prefix: Option<String>,
199    #[arg(long, short, value_name = "FILE")]
200    output: Option<PathBuf>,
201}
202
203impl QueryArgs {
204    fn filter(&self) -> Option<QueryLabelFilter> {
205        self.label
206            .clone()
207            .map(QueryLabelFilter::Exact)
208            .or_else(|| self.label_prefix.clone().map(QueryLabelFilter::Prefix))
209    }
210}
211
212#[derive(Args, Debug)]
213struct OverviewArgs {
214    /// Raw trace, finalized bundle/profile directory, or its `trace.jsonl`.
215    #[arg(value_name = "INPUT")]
216    input: PathBuf,
217    #[arg(long, short, value_name = "FILE")]
218    output: Option<PathBuf>,
219}
220
221#[derive(Args, Debug)]
222struct CompareArgs {
223    /// Finalized evidence bundle directories, unless `--unverified-traces` is set.
224    #[arg(long, required = true, num_args = 1.., value_name = "BUNDLE")]
225    baseline: Vec<PathBuf>,
226    /// Finalized evidence bundle directories, unless `--unverified-traces` is set.
227    #[arg(long, required = true, num_args = 1.., value_name = "BUNDLE")]
228    candidate: Vec<PathBuf>,
229    /// Treat baseline and candidate paths as raw traces; output is always diagnostic/ineligible.
230    #[arg(long)]
231    unverified_traces: bool,
232    /// Exit nonzero (after writing output) when the comparison verdict is ineligible.
233    #[arg(long)]
234    require_eligible: bool,
235    #[arg(long, short, value_name = "FILE")]
236    output: Option<PathBuf>,
237}
238
239#[derive(Args, Debug)]
240struct ReportArgs {
241    /// Raw trace JSONL file (`candle-graph/trace/10` or readable trace/9).
242    #[arg(value_name = "TRACE")]
243    trace: PathBuf,
244    #[arg(long, value_name = "DIR")]
245    nsight_dir: Option<PathBuf>,
246    #[arg(long, value_name = "DIR")]
247    bundle: PathBuf,
248    /// Publication receipt destination (stdout when omitted); must be outside the new bundle.
249    #[arg(long, short, value_name = "FILE")]
250    output: Option<PathBuf>,
251}
252
253#[derive(Args, Debug)]
254struct VerifyArgs {
255    /// Finalized evidence bundle directory.
256    #[arg(value_name = "BUNDLE")]
257    bundle: PathBuf,
258    /// Also rederive the evidence packet from retained inputs and require an exact match.
259    #[arg(long)]
260    semantic: bool,
261    #[arg(long, short, value_name = "FILE")]
262    output: Option<PathBuf>,
263}
264
265#[derive(Args, Debug)]
266struct ProtocolArgs {
267    #[arg(long, short, value_name = "FILE")]
268    output: Option<PathBuf>,
269}
270
271#[derive(Args, Debug)]
272struct CampaignStatusArgs {
273    /// Campaign manifest (`candle-graph/campaign/1`).
274    #[arg(long, value_name = "FILE")]
275    manifest: PathBuf,
276    #[arg(long, short, value_name = "FILE")]
277    output: Option<PathBuf>,
278}
279
280#[derive(Args, Debug)]
281struct SeriesArgs {
282    /// Campaign manifest; every planned capture must already be published.
283    #[arg(
284        long,
285        value_name = "FILE",
286        conflicts_with = "bundle",
287        required_unless_present = "bundle"
288    )]
289    manifest: Option<PathBuf>,
290    /// Explicit ordered verified bundle directories.
291    #[arg(long, value_name = "DIR", num_args = 1..)]
292    bundle: Vec<PathBuf>,
293    /// Restrict tensor-stat and gradient series to labels with this prefix.
294    #[arg(long, value_name = "P")]
295    label_prefix: Option<String>,
296    #[arg(long, short, value_name = "FILE")]
297    output: Option<PathBuf>,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
301enum CliTraceQueryKind {
302    SlowestHost,
303    SlowestDevice,
304    Heaviest,
305    Memory,
306    Spans,
307    Tensors,
308    TensorStats,
309    Gradients,
310    Capabilities,
311    GpuStatus,
312    GpuCorrelation,
313    GpuPhases,
314    GpuKernels,
315    GpuAttributionGaps,
316}
317
318impl From<CliTraceQueryKind> for TraceQueryKind {
319    fn from(kind: CliTraceQueryKind) -> Self {
320        match kind {
321            CliTraceQueryKind::SlowestHost => Self::SlowestHost,
322            CliTraceQueryKind::SlowestDevice => Self::SlowestDevice,
323            CliTraceQueryKind::Heaviest => Self::Heaviest,
324            CliTraceQueryKind::Memory => Self::Memory,
325            CliTraceQueryKind::Spans => Self::Spans,
326            CliTraceQueryKind::Tensors => Self::Tensors,
327            CliTraceQueryKind::TensorStats => Self::TensorStats,
328            CliTraceQueryKind::Gradients => Self::Gradients,
329            CliTraceQueryKind::Capabilities => Self::Capabilities,
330            CliTraceQueryKind::GpuStatus => Self::GpuStatus,
331            CliTraceQueryKind::GpuCorrelation => Self::GpuCorrelation,
332            CliTraceQueryKind::GpuPhases => Self::GpuPhases,
333            CliTraceQueryKind::GpuKernels => Self::GpuKernels,
334            CliTraceQueryKind::GpuAttributionGaps => Self::GpuAttributionGaps,
335        }
336    }
337}