Skip to main content

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