hugr_cli/
lib.rs

1//! Standard command line tools for the HUGR format.
2//!
3//! This library provides utilities for the HUGR CLI.
4//!
5//! ## CLI Usage
6//!
7//! Run `cargo install hugr-cli` to install the CLI tools. This will make the
8//! `hugr` executable available in your shell as long as you have [cargo's bin
9//! directory](https://doc.rust-lang.org/book/ch14-04-installing-binaries.html)
10//! in your path.
11//!
12//! The top level help can be accessed with:
13//! ```sh
14//! hugr --help
15//! ```
16//!
17//! Refer to the help for each subcommand for more information, e.g.
18//! ```sh
19//! hugr validate --help
20//! ```
21
22use std::ffi::OsString;
23
24use anyhow::Result;
25use clap::{Parser, crate_version};
26#[cfg(feature = "tracing")]
27use clap_verbosity_flag::VerbosityFilter;
28use clap_verbosity_flag::{InfoLevel, Verbosity};
29use hugr::package::PackageValidationError;
30use thiserror::Error;
31#[cfg(feature = "tracing")]
32use tracing::{error, metadata::LevelFilter};
33
34pub mod convert;
35pub mod describe;
36pub mod extensions;
37pub mod hugr_io;
38pub mod mermaid;
39pub mod validate;
40
41/// CLI arguments.
42#[derive(Parser, Debug)]
43#[clap(version = crate_version!(), long_about = None)]
44#[clap(about = "HUGR CLI tools.")]
45#[group(id = "hugr")]
46pub struct CliArgs {
47    /// The command to be run.
48    #[command(subcommand)]
49    pub command: CliCommand,
50    /// Verbosity.
51    #[command(flatten)]
52    pub verbose: Verbosity<InfoLevel>,
53}
54
55/// The CLI subcommands.
56#[derive(Debug, clap::Subcommand)]
57#[non_exhaustive]
58pub enum CliCommand {
59    /// Validate a HUGR package.
60    Validate(validate::ValArgs),
61    /// Write standard extensions out in serialized form.
62    GenExtensions(extensions::ExtArgs),
63    /// Write HUGR as mermaid diagrams.
64    Mermaid(mermaid::MermaidArgs),
65    /// Convert between different HUGR envelope formats.
66    Convert(convert::ConvertArgs),
67    /// External commands
68    #[command(external_subcommand)]
69    External(Vec<OsString>),
70
71    /// Describe the contents of a HUGR package.
72    ///
73    /// If an error occurs during loading partial descriptions are printed.
74    /// For example if the first module is loaded and the second fails then
75    /// only the first module will be described.
76    Describe(describe::DescribeArgs),
77}
78
79/// Error type for the CLI.
80#[derive(Debug, Error)]
81#[non_exhaustive]
82pub enum CliError {
83    /// Error reading input.
84    #[error("Error reading from path.")]
85    InputFile(#[from] std::io::Error),
86    /// Error parsing input.
87    #[error("Error parsing package.")]
88    Parse(#[from] serde_json::Error),
89    #[error("Error validating HUGR.")]
90    /// Errors produced by the `validate` subcommand.
91    Validate(#[from] PackageValidationError),
92    /// Pretty error when the user passes a non-envelope file.
93    #[error(
94        "Input file is not a HUGR envelope. Invalid magic number.\n\nUse `--hugr-json` to read a raw HUGR JSON file instead."
95    )]
96    NotAnEnvelope,
97    /// Invalid format string for conversion.
98    #[error(
99        "Invalid format: '{_0}'. Valid formats are: json, model, model-exts, model-text, model-text-exts"
100    )]
101    InvalidFormat(String),
102    #[error("Error validating HUGR generated by {generator}")]
103    /// Errors produced by the `validate` subcommand, with a known generator of the HUGR.
104    ValidateKnownGenerator {
105        #[source]
106        /// The inner validation error.
107        inner: PackageValidationError,
108        /// The generator of the HUGR.
109        generator: Box<String>,
110    },
111    #[error("Error reading envelope.")]
112    /// Errors produced when reading an envelope.
113    ReadEnvelope(#[from] hugr::envelope::ReadError),
114}
115
116impl CliError {
117    /// Returns a validation error, with an optional generator.
118    pub fn validation(generator: Option<String>, val_err: PackageValidationError) -> Self {
119        if let Some(g) = generator {
120            Self::ValidateKnownGenerator {
121                inner: val_err,
122                generator: Box::new(g.to_string()),
123            }
124        } else {
125            Self::Validate(val_err)
126        }
127    }
128}
129
130impl CliCommand {
131    /// Run a CLI command with optional input/output overrides.
132    /// If overrides are `None`, behaves like the normal CLI.
133    /// If overrides are provided, stdin/stdout/files are ignored.
134    /// The `gen-extensions` and `external` commands don't support overrides.
135    ///
136    /// # Arguments
137    ///
138    /// * `input_override` - Optional reader to use instead of stdin/files
139    /// * `output_override` - Optional writer to use instead of stdout/files
140    ///
141    fn run_with_io<R: std::io::Read, W: std::io::Write>(
142        self,
143        input_override: Option<R>,
144        output_override: Option<W>,
145    ) -> Result<()> {
146        match self {
147            Self::Validate(mut args) => args.run_with_input(input_override),
148            Self::GenExtensions(args) => {
149                if input_override.is_some() || output_override.is_some() {
150                    return Err(anyhow::anyhow!(
151                        "GenExtensions command does not support programmatic I/O overrides"
152                    ));
153                }
154                args.run_dump(&hugr::std_extensions::STD_REG)
155            }
156            Self::Mermaid(mut args) => args.run_print_with_io(input_override, output_override),
157            Self::Convert(mut args) => args.run_convert_with_io(input_override, output_override),
158            Self::Describe(mut args) => args.run_describe_with_io(input_override, output_override),
159            Self::External(args) => {
160                if input_override.is_some() || output_override.is_some() {
161                    return Err(anyhow::anyhow!(
162                        "External commands do not support programmatic I/O overrides"
163                    ));
164                }
165                run_external(args)
166            }
167        }
168    }
169}
170
171impl Default for CliArgs {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl CliArgs {
178    /// Parse CLI arguments from the environment.
179    pub fn new() -> Self {
180        CliArgs::parse()
181    }
182
183    /// Parse CLI arguments from an iterator.
184    pub fn new_from_args<I, T>(args: I) -> Self
185    where
186        I: IntoIterator<Item = T>,
187        T: Into<std::ffi::OsString> + Clone,
188    {
189        CliArgs::parse_from(args)
190    }
191
192    /// Entrypoint for cli - process arguments and run commands.
193    ///
194    /// Process exits on error.
195    pub fn run_cli(self) {
196        #[cfg(feature = "tracing")]
197        {
198            let level = match self.verbose.filter() {
199                VerbosityFilter::Off => LevelFilter::OFF,
200                VerbosityFilter::Error => LevelFilter::ERROR,
201                VerbosityFilter::Warn => LevelFilter::WARN,
202                VerbosityFilter::Info => LevelFilter::INFO,
203                VerbosityFilter::Debug => LevelFilter::DEBUG,
204                VerbosityFilter::Trace => LevelFilter::TRACE,
205            };
206            tracing_subscriber::fmt()
207                .with_writer(std::io::stderr)
208                .with_max_level(level)
209                .pretty()
210                .init();
211        }
212
213        let result = self
214            .command
215            .run_with_io(None::<std::io::Stdin>, None::<std::io::Stdout>);
216
217        if let Err(err) = result {
218            #[cfg(feature = "tracing")]
219            error!("{:?}", err);
220            #[cfg(not(feature = "tracing"))]
221            eprintln!("{:?}", err);
222            std::process::exit(1);
223        }
224    }
225
226    /// Run a CLI command with bytes input and capture bytes output.
227    ///
228    /// This provides a programmatic interface to the CLI.
229    /// Unlike `run_cli()`, this method:
230    /// - Accepts input instead of reading from stdin/files
231    /// - Returns output as a byte vector instead of writing to stdout/files
232    ///
233    /// # Arguments
234    ///
235    /// * `input` - The input data as bytes (e.g., a HUGR package)
236    ///
237    /// # Returns
238    ///
239    /// Returns `Ok(Vec<u8>)` with the command output, or an error on failure.
240    ///
241    ///
242    /// # Note
243    ///
244    /// The `gen-extensions` and `external` commands don't support byte I/O
245    /// and should use the normal `run_cli()` method instead.
246    pub fn run_with_io(self, input: impl std::io::Read) -> Result<Vec<u8>, RunWithIoError> {
247        let mut output = Vec::new();
248        let is_describe = matches!(self.command, CliCommand::Describe(_));
249        let res = self.command.run_with_io(Some(input), Some(&mut output));
250        match (res, is_describe) {
251            (Ok(()), _) => Ok(output),
252            (Err(e), true) => Err(RunWithIoError::Describe { source: e, output }),
253            (Err(e), false) => Err(RunWithIoError::Other(e)),
254        }
255    }
256}
257
258#[derive(Debug, Error)]
259#[non_exhaustive]
260#[error("Error running CLI command with IO.")]
261/// Error type for `run_with_io` method.
262pub enum RunWithIoError {
263    /// Error describing HUGR package.
264    Describe {
265        #[source]
266        /// Error returned from describe command.
267        source: anyhow::Error,
268        /// Describe command output.
269        output: Vec<u8>,
270    },
271    /// Non-describe command error.
272    Other(anyhow::Error),
273}
274
275fn run_external(args: Vec<OsString>) -> Result<()> {
276    // External subcommand support: invoke `hugr-<subcommand>`
277    if args.is_empty() {
278        eprintln!("No external subcommand specified.");
279        std::process::exit(1);
280    }
281    let subcmd = args[0].to_string_lossy();
282    let exe = format!("hugr-{subcmd}");
283    let rest: Vec<_> = args[1..]
284        .iter()
285        .map(|s| s.to_string_lossy().to_string())
286        .collect();
287    match std::process::Command::new(&exe).args(&rest).status() {
288        Ok(status) => {
289            if !status.success() {
290                std::process::exit(status.code().unwrap_or(1));
291            }
292        }
293        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
294            eprintln!("error: no such subcommand: '{subcmd}'.\nCould not find '{exe}' in PATH.");
295            std::process::exit(1);
296        }
297        Err(e) => {
298            eprintln!("error: failed to invoke '{exe}': {e}");
299            std::process::exit(1);
300        }
301    }
302
303    Ok(())
304}