From 7482397a00ea9c02a3d4a0c6a85adf4726dd58b3 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Tue, 7 Oct 2025 11:38:51 +0100 Subject: [PATCH 1/6] feat(cli): describe sub-command --- Cargo.lock | 68 +++++++++ hugr-cli/Cargo.toml | 2 + hugr-cli/src/describe.rs | 228 ++++++++++++++++++++++++++++++ hugr-cli/src/lib.rs | 5 +- hugr-cli/src/main.rs | 2 + hugr-cli/tests/describe.rs | 276 +++++++++++++++++++++++++++++++++++++ 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 hugr-cli/src/describe.rs create mode 100644 hugr-cli/tests/describe.rs diff --git a/Cargo.lock b/Cargo.lock index 592cee7ca8..c10b48d0a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1203,7 +1203,9 @@ dependencies = [ "hugr", "predicates", "rstest", + "serde", "serde_json", + "tabled", "tempfile", "thiserror 2.0.17", "tracing", @@ -1970,6 +1972,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2228,6 +2241,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.102" @@ -2963,6 +2998,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tap" version = "1.0.1" @@ -2994,6 +3053,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/hugr-cli/Cargo.toml b/hugr-cli/Cargo.toml index d2c2a6b0eb..286b9b869f 100644 --- a/hugr-cli/Cargo.toml +++ b/hugr-cli/Cargo.toml @@ -21,11 +21,13 @@ clap-verbosity-flag.workspace = true derive_more = { workspace = true, features = ["display", "error", "from"] } hugr = { path = "../hugr", version = "0.24.0" } serde_json.workspace = true +serde = { workspace = true, features = ["derive"] } clio = { workspace = true, features = ["clap-parse"] } anyhow.workspace = true thiserror.workspace = true tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["fmt"] } +tabled = "0.20.0" [lints] workspace = true diff --git a/hugr-cli/src/describe.rs b/hugr-cli/src/describe.rs new file mode 100644 index 0000000000..d358be31aa --- /dev/null +++ b/hugr-cli/src/describe.rs @@ -0,0 +1,228 @@ +//! Convert between different HUGR envelope formats. +use std::io::Write; + +use crate::hugr_io::HugrInputArgs; +use anyhow::Result; +use clap::Parser; +use clio::Output; +use hugr::NodeIndex; +use hugr::envelope::ReadError; +use hugr::envelope::description::{ExtensionDesc, ModuleDesc, PackageDesc}; +use hugr::extension::Version; +use hugr::package::Package; +use tabled::Tabled; +use tabled::derive::display; + +/// Convert between different HUGR envelope formats. +#[derive(Parser, Debug)] +#[clap(version = "1.0", long_about = None)] +#[clap(about = "Describe the contents of a HUGR envelope.")] +#[group(id = "hugr")] +#[non_exhaustive] +pub struct DescribeArgs { + /// Hugr input. + #[command(flatten)] + pub input_args: HugrInputArgs, + /// enumerate packaged extensions + #[arg(long, default_value = "false")] + pub packaged_extensions: bool, + + #[command(flatten)] + /// Configure module description + pub module_args: ModuleArgs, + + #[arg(long, default_value = "false")] + /// Output in json format + pub json: bool, + + /// Output file. Use '-' for stdout. + #[clap(short, long, value_parser, default_value = "-")] + pub output: Output, +} + +/// Arguments for reading a HUGR input. +#[derive(Debug, clap::Args)] +pub struct ModuleArgs { + #[arg(long, default_value = "false")] + /// Don't display resolved extensions used by the module. + pub no_resolved_extensions: bool, + + #[arg(long, default_value = "false")] + /// Display public symbols in the module. + pub public_symbols: bool, + + #[arg(long, default_value = "false")] + /// Display claimed extensions set by generator in module metadata. + pub generator_claimed_extensions: bool, +} +impl ModuleArgs { + fn filter_module(&self, module: &mut ModuleDesc) { + if self.no_resolved_extensions { + module.used_extensions_resolved = None; + } + if !self.public_symbols { + module.public_symbols = None; + } + if !self.generator_claimed_extensions { + module.used_extensions_generator = None; + } + } +} +impl DescribeArgs { + /// Convert a HUGR between different envelope formats + pub fn run_describe(&mut self) -> Result<()> { + let (mut desc, res) = match self.input_args.get_described_package() { + Ok((desc, pkg)) => (desc, Ok(pkg)), + Err(crate::CliError::ReadEnvelope(ReadError::Payload { + source, + partial_description, + })) => (partial_description, Err(source)), // keep error for later + Err(e) => return Err(e.into()), + }; + + // clear fields that have not been requested + for module in desc.modules.iter_mut().flatten() { + self.module_args.filter_module(module); + } + + let res = res.map_err(anyhow::Error::from); + if self.json { + if !self.packaged_extensions { + desc.packaged_extensions.clear(); + } + self.output_json(desc, &res)?; + } else { + self.print_description(desc)?; + } + + // bubble up any errors + res.map(|_| ()) + } + + fn print_description(&mut self, desc: PackageDesc) -> Result<()> { + let header = desc.header(); + writeln!( + self.output, + "{header}\nPackage contains {} module(s) and {} extension(s)", + desc.n_modules(), + desc.n_packaged_extensions() + )?; + let summaries: Vec = desc + .modules + .iter() + .map(|m| m.as_ref().map(Into::into).unwrap_or_default()) + .collect(); + let summary_table = tabled::Table::builder(summaries).index().build(); + writeln!(self.output, "{summary_table}")?; + + for (i, module) in desc.modules.into_iter().enumerate() { + writeln!(self.output, "\nModule {i}:")?; + if let Some(module) = module { + self.display_module(module)?; + } + } + if self.packaged_extensions { + writeln!(self.output, "Packaged extensions:")?; + let ext_rows: Vec = desc + .packaged_extensions + .into_iter() + .flatten() + .map(Into::into) + .collect(); + let ext_table = tabled::Table::new(ext_rows); + writeln!(self.output, "{ext_table}")?; + } + Ok(()) + } + + fn output_json(&mut self, package_desc: PackageDesc, res: &Result) -> Result<()> { + let err_str = res.as_ref().err().map(|e| format!("{e:?}")); + let json_desc = JsonDescription { + package_desc, + error: err_str, + }; + serde_json::to_writer_pretty(&mut self.output, &json_desc)?; + Ok(()) + } + + fn display_module(&mut self, desc: ModuleDesc) -> Result<()> { + if let Some(exts) = desc.used_extensions_resolved { + let ext_rows: Vec = exts.into_iter().map(Into::into).collect(); + let ext_table = tabled::Table::new(ext_rows); + writeln!(self.output, "Resolved extensions:\n{ext_table}")?; + } + + if let Some(syms) = desc.public_symbols { + let sym_table = tabled::Table::new(syms.into_iter().map(|s| SymbolRow { symbol: s })); + writeln!(self.output, "Public symbols:\n{sym_table}")?; + } + + if let Some(exts) = desc.used_extensions_generator { + let ext_rows: Vec = exts.into_iter().map(Into::into).collect(); + let ext_table = tabled::Table::new(ext_rows); + writeln!(self.output, "Generator claimed extensions:\n{ext_table}")?; + } + + Ok(()) + } +} + +#[derive(serde::Serialize)] +struct JsonDescription { + #[serde(flatten)] + package_desc: PackageDesc, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Tabled)] +struct ExtensionRow { + name: String, + version: Version, +} + +#[derive(Tabled)] +struct SymbolRow { + #[tabled(rename = "Symbol")] + symbol: String, +} + +impl From for ExtensionRow { + fn from(desc: ExtensionDesc) -> Self { + Self { + name: desc.name, + version: desc.version, + } + } +} + +#[derive(Tabled, Default)] +struct ModuleSummary { + #[tabled(display("display::option", "n/a"))] + num_nodes: Option, + #[tabled(display("display::option", "n/a"))] + entrypoint_node: Option, + #[tabled(display("display::option", "n/a"))] + entrypoint_op: Option, + #[tabled(display("display::option", "n/a"))] + generator: Option, +} + +impl From<&ModuleDesc> for ModuleSummary { + fn from(desc: &ModuleDesc) -> Self { + let (entrypoint_node, entrypoint_op) = if let Some(ep) = &desc.entrypoint { + ( + Some(ep.node.index()), + Some(hugr::envelope::description::op_string(&ep.optype)), + ) + } else { + (None, None) + }; + Self { + num_nodes: desc.num_nodes, + entrypoint_node, + entrypoint_op, + generator: desc.generator.clone(), + } + } +} diff --git a/hugr-cli/src/lib.rs b/hugr-cli/src/lib.rs index 3b38393a1a..8d5745151f 100644 --- a/hugr-cli/src/lib.rs +++ b/hugr-cli/src/lib.rs @@ -27,11 +27,11 @@ use std::ffi::OsString; use thiserror::Error; pub mod convert; +pub mod describe; pub mod extensions; pub mod hugr_io; pub mod mermaid; pub mod validate; - /// CLI arguments. #[derive(Parser, Debug)] #[clap(version = crate_version!(), long_about = None)] @@ -61,6 +61,9 @@ pub enum CliCommand { /// External commands #[command(external_subcommand)] External(Vec), + + /// Describe the contents of a HUGR envelope. + Describe(describe::DescribeArgs), } /// Error type for the CLI. diff --git a/hugr-cli/src/main.rs b/hugr-cli/src/main.rs index 28e64020ff..1474877ab3 100644 --- a/hugr-cli/src/main.rs +++ b/hugr-cli/src/main.rs @@ -30,12 +30,14 @@ fn main() { CliCommand::GenExtensions(args) => args.run_dump(&hugr::std_extensions::STD_REG), CliCommand::Mermaid(mut args) => args.run_print(), CliCommand::Convert(mut args) => args.run_convert(), + CliCommand::Describe(mut args) => args.run_describe(), CliCommand::External(args) => run_external(args), _ => Err(anyhow!("Unknown command")), }; if let Err(err) = result { error!("{:?}", err); + // TODO include description if verbosity is high enough std::process::exit(1); } } diff --git a/hugr-cli/tests/describe.rs b/hugr-cli/tests/describe.rs new file mode 100644 index 0000000000..7f17b4b868 --- /dev/null +++ b/hugr-cli/tests/describe.rs @@ -0,0 +1,276 @@ +//! Tests for describe subcommand of the CLI +//! +//! Miri is globally disabled for these tests because they mostly involve +//! calling the CLI binary, which Miri doesn't support. +#![cfg(all(test, not(miri)))] + +use assert_cmd::Command; +use assert_fs::NamedTempFile; +use assert_fs::assert::PathAssert; +use assert_fs::fixture::FileWriteBin; +use hugr::Extension; +use hugr::builder::ModuleBuilder; +use hugr::builder::{Dataflow, DataflowSubContainer, HugrBuilder}; +use hugr::core::Visibility; +use hugr::envelope::{ + EnvelopeConfig, EnvelopeFormat, EnvelopeHeader, GENERATOR_KEY, USED_EXTENSIONS_KEY, +}; +use hugr::extension::prelude::bool_t; +use hugr::extension::{ExtensionId, ExtensionRegistry, Version}; +use hugr::hugr::HugrView; +use hugr::hugr::hugrmut::HugrMut; +use hugr::ops::handle::NodeHandle; +use hugr::package::Package; +use hugr::types::Signature; +use predicates::{prelude::*, str::contains}; +use rstest::{fixture, rstest}; +use serde_json::Value; +use serde_json::json; +use std::io::Write; +use std::sync::Arc; +#[fixture] +fn cmd() -> Command { + Command::cargo_bin("hugr").unwrap() +} + +#[fixture] +fn describe_cmd(mut cmd: Command) -> Command { + cmd.arg("describe"); + cmd +} + +#[fixture] +fn empty_package() -> Package { + Package::new(vec![]) +} + +#[fixture] +fn empty_package_file(empty_package: Package) -> NamedTempFile { + let file = assert_fs::NamedTempFile::new("valid.hugr").unwrap(); + let mut buffer = Vec::new(); + empty_package + .store(&mut buffer, EnvelopeConfig::default()) + .unwrap(); + file.write_binary(&buffer).unwrap(); + file +} + +#[fixture] +fn invalid_package() -> Vec { + let header = EnvelopeHeader { + format: EnvelopeFormat::Model, + ..Default::default() + }; + let mut buffer = Vec::new(); + header.write(&mut buffer).unwrap(); + buffer + .write_all(b"this is not valid hugr package data") + .unwrap(); + buffer +} + +#[fixture] +fn package_with_exts() -> Vec { + let test_ext = Extension::new_arc( + ExtensionId::new_unchecked("resolved_ext"), + Version::new(0, 1, 0), + |ext, extension_ref| { + ext.add_op( + "Id".into(), + "".into(), + Signature::new_endo(bool_t()), + extension_ref, + ) + .unwrap(); + }, + ); + let mut module = ModuleBuilder::new(); + let mut df = module + .define_function("entry_fn", Signature::new_endo(bool_t())) + .unwrap(); + let [i] = df.input_wires_arr(); + let i = df + .add_dataflow_op(test_ext.instantiate_extension_op("Id", []).unwrap(), [i]) + .unwrap() + .out_wire(0); + let f_n = df.finish_with_outputs([i]).unwrap().node(); + module + .declare_vis( + "public_fn", + Signature::new_endo(bool_t()).into(), + Visibility::Public, + ) + .unwrap(); + let mut hugr = module.finish_hugr().unwrap(); + hugr.set_entrypoint(f_n); + hugr.set_metadata( + hugr.module_root(), + USED_EXTENSIONS_KEY, + json!([{ "name": "used_ext", "version": "1.0.0" }]), + ); + hugr.set_metadata( + hugr.module_root(), + GENERATOR_KEY, + json!({ "name": "my_generator", "version": "2.0.0" }), + ); + let mut package = Package::new(vec![hugr]); + let packed_ext = Extension::new( + ExtensionId::new_unchecked("packed_ext"), + Version::new(0, 1, 0), + ); + package.extensions = ExtensionRegistry::new([Arc::new(packed_ext), test_ext]); + let mut buffer = Vec::new(); + package + .store(&mut buffer, EnvelopeConfig::default()) + .unwrap(); + buffer +} + +#[fixture] +fn package_with_exts_file(package_with_exts: Vec) -> NamedTempFile { + let file = assert_fs::NamedTempFile::new("valid_with_extensions.hugr").unwrap(); + + file.write_binary(&package_with_exts).unwrap(); + file +} + +#[rstest] +fn test_describe_basic(empty_package_file: NamedTempFile, mut describe_cmd: Command) { + describe_cmd.arg(empty_package_file.path()); + describe_cmd + .assert() + .success() + .stdout(contains("Package contains 0 module(s) and 0 extension(s)")); +} + +#[rstest] +fn test_describe_json(package_with_exts_file: NamedTempFile, mut describe_cmd: Command) { + describe_cmd.arg(package_with_exts_file.path()); + describe_cmd.arg("--json"); + describe_cmd.arg("--packaged-extensions"); + let output = describe_cmd.assert().success().get_output().stdout.clone(); + let json: Value = serde_json::from_slice(&output).unwrap(); + let expected_json = json!({ + "header": "EnvelopeHeader(PackageJson)", + "modules": [ + { + "entrypoint": { + "node": 1, + "optype": "FuncDefn(entry_fn: [Bool] -> [Bool])" + }, + "generator": "my_generator-v2.0.0", + "num_nodes": 6, + "used_extensions_resolved": [ + { + "name": "resolved_ext", + "version": "0.1.0" + } + ] + } + ], + "packaged_extensions": [ + { + "name": "packed_ext", + "version": "0.1.0" + }, + { + "name": "resolved_ext", + "version": "0.1.0" + } + ] + }); + assert_eq!(json, expected_json); +} + +#[rstest] +fn test_describe_packaged_extensions( + package_with_exts_file: NamedTempFile, + mut describe_cmd: Command, +) { + describe_cmd.args([ + package_with_exts_file.path(), + std::path::Path::new("--packaged-extensions"), + ]); + describe_cmd + .assert() + .success() + .stdout(contains("my_generator-v2.0.0")) + .stdout(contains("Resolved extensions:")) + .stdout(contains("Packaged extensions:")) + .stdout(contains("used_ext").not()) + .stdout(contains("public_fn").not()) + .stdout(contains("resolved_ext")) + .stdout(contains("Generator claimed extensions").not()) + .stdout(contains("packed_ext")); +} + +#[rstest] +fn test_describe_output_redirection(empty_package_file: NamedTempFile, mut describe_cmd: Command) { + let output_file = assert_fs::NamedTempFile::new("output.txt").unwrap(); + describe_cmd.args([ + empty_package_file.path(), + std::path::Path::new("--output"), + output_file.path(), + ]); + describe_cmd.assert().success(); + output_file.assert(contains("Package contains 0 module(s) and 0 extension(s)")); +} + +#[rstest] +fn test_no_resolved_extensions(package_with_exts: Vec, mut describe_cmd: Command) { + describe_cmd.write_stdin(package_with_exts); + + describe_cmd.arg("--no-resolved-extensions"); + describe_cmd + .assert() + .success() + .stdout(contains("Packaged extensions:").not()) + .stdout(contains("Resolved extensions:").not()); +} + +#[rstest] +fn test_public_symbols(package_with_exts: Vec, mut describe_cmd: Command) { + describe_cmd.write_stdin(package_with_exts); + + describe_cmd.arg("--public-symbols"); + describe_cmd + .assert() + .success() + .stdout(contains("Public symbols:")) + .stdout(contains("public_fn")); +} + +#[rstest] +fn test_generator_claimed_extensions(package_with_exts: Vec, mut describe_cmd: Command) { + describe_cmd.write_stdin(package_with_exts); + + describe_cmd.arg("--generator-claimed-extensions"); + describe_cmd + .assert() + .success() + .stdout(contains("Generator claimed extensions")) + .stdout(contains("used_ext")); +} + +#[rstest] +fn test_describe_invalid_package(invalid_package: Vec, mut describe_cmd: Command) { + describe_cmd.write_stdin(invalid_package); + describe_cmd + .assert() + .failure() + .stdout(contains("Model")) + .stderr(contains("Error reading a HUGR model payload")); +} + +#[rstest] +fn test_describe_invalid_package_json(invalid_package: Vec, mut describe_cmd: Command) { + describe_cmd.write_stdin(invalid_package); + + describe_cmd.arg("--json"); + describe_cmd + .assert() + .failure() + .stdout(contains("Model")) + .stdout(contains("\"error\": \"Error reading a HUGR model payload")) // error included in JSON + .stderr(contains("Error reading a HUGR model payload")); +} From 660fdb6a0425aad9e360e58c6609a89e45876ea9 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 29 Oct 2025 13:52:40 +0000 Subject: [PATCH 2/6] review suggestions --- hugr-cli/src/describe.rs | 8 ++++---- hugr-cli/src/lib.rs | 6 +++++- hugr-cli/src/main.rs | 1 - 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/hugr-cli/src/describe.rs b/hugr-cli/src/describe.rs index d358be31aa..490aebb243 100644 --- a/hugr-cli/src/describe.rs +++ b/hugr-cli/src/describe.rs @@ -1,4 +1,4 @@ -//! Convert between different HUGR envelope formats. +//! Describe the contents of HUGR packages. use std::io::Write; use crate::hugr_io::HugrInputArgs; @@ -13,7 +13,7 @@ use hugr::package::Package; use tabled::Tabled; use tabled::derive::display; -/// Convert between different HUGR envelope formats. +/// Describe the contents of a serialized HUGR package. #[derive(Parser, Debug)] #[clap(version = "1.0", long_about = None)] #[clap(about = "Describe the contents of a HUGR envelope.")] @@ -23,7 +23,7 @@ pub struct DescribeArgs { /// Hugr input. #[command(flatten)] pub input_args: HugrInputArgs, - /// enumerate packaged extensions + /// Enumerate packaged extensions #[arg(long, default_value = "false")] pub packaged_extensions: bool, @@ -69,7 +69,7 @@ impl ModuleArgs { } } impl DescribeArgs { - /// Convert a HUGR between different envelope formats + /// Load and describe the HUGR package. pub fn run_describe(&mut self) -> Result<()> { let (mut desc, res) = match self.input_args.get_described_package() { Ok((desc, pkg)) => (desc, Ok(pkg)), diff --git a/hugr-cli/src/lib.rs b/hugr-cli/src/lib.rs index 8d5745151f..688d277ee4 100644 --- a/hugr-cli/src/lib.rs +++ b/hugr-cli/src/lib.rs @@ -62,7 +62,11 @@ pub enum CliCommand { #[command(external_subcommand)] External(Vec), - /// Describe the contents of a HUGR envelope. + /// Describe the contents of a HUGR package. + /// + /// If an error occurs during loading partial descriptions are printed, + /// for example if the first module is loaded and the second fails the + /// only the first module will be described. Describe(describe::DescribeArgs), } diff --git a/hugr-cli/src/main.rs b/hugr-cli/src/main.rs index 1474877ab3..48acd2a74f 100644 --- a/hugr-cli/src/main.rs +++ b/hugr-cli/src/main.rs @@ -37,7 +37,6 @@ fn main() { if let Err(err) = result { error!("{:?}", err); - // TODO include description if verbosity is high enough std::process::exit(1); } } From 366ff2c41bee92662a74b77e644dcd3775fdd47d Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 29 Oct 2025 14:58:19 +0000 Subject: [PATCH 3/6] help headings --- hugr-cli/src/describe.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hugr-cli/src/describe.rs b/hugr-cli/src/describe.rs index 490aebb243..840357c272 100644 --- a/hugr-cli/src/describe.rs +++ b/hugr-cli/src/describe.rs @@ -24,7 +24,7 @@ pub struct DescribeArgs { #[command(flatten)] pub input_args: HugrInputArgs, /// Enumerate packaged extensions - #[arg(long, default_value = "false")] + #[arg(long, default_value = "false", help_heading = "Filter")] pub packaged_extensions: bool, #[command(flatten)] @@ -43,15 +43,15 @@ pub struct DescribeArgs { /// Arguments for reading a HUGR input. #[derive(Debug, clap::Args)] pub struct ModuleArgs { - #[arg(long, default_value = "false")] + #[arg(long, default_value = "false", help_heading = "Filter")] /// Don't display resolved extensions used by the module. pub no_resolved_extensions: bool, - #[arg(long, default_value = "false")] + #[arg(long, default_value = "false", help_heading = "Filter")] /// Display public symbols in the module. pub public_symbols: bool, - #[arg(long, default_value = "false")] + #[arg(long, default_value = "false", help_heading = "Filter")] /// Display claimed extensions set by generator in module metadata. pub generator_claimed_extensions: bool, } From 514e9d29192c1a8fbc216d171a8fe5ed5fc7a123 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 29 Oct 2025 15:01:55 +0000 Subject: [PATCH 4/6] detect singular --- hugr-cli/src/describe.rs | 12 +++++++++--- hugr-cli/tests/describe.rs | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/hugr-cli/src/describe.rs b/hugr-cli/src/describe.rs index 840357c272..934ee9f138 100644 --- a/hugr-cli/src/describe.rs +++ b/hugr-cli/src/describe.rs @@ -101,11 +101,17 @@ impl DescribeArgs { fn print_description(&mut self, desc: PackageDesc) -> Result<()> { let header = desc.header(); + let n_modules = desc.n_modules(); + let n_extensions = desc.n_packaged_extensions(); + let module_str = if n_modules == 1 { "module" } else { "modules" }; + let extension_str = if n_extensions == 1 { + "extension" + } else { + "extensions" + }; writeln!( self.output, - "{header}\nPackage contains {} module(s) and {} extension(s)", - desc.n_modules(), - desc.n_packaged_extensions() + "{header}\nPackage contains {n_modules} {module_str} and {n_extensions} {extension_str}", )?; let summaries: Vec = desc .modules diff --git a/hugr-cli/tests/describe.rs b/hugr-cli/tests/describe.rs index 7f17b4b868..30f865f5ca 100644 --- a/hugr-cli/tests/describe.rs +++ b/hugr-cli/tests/describe.rs @@ -140,7 +140,7 @@ fn test_describe_basic(empty_package_file: NamedTempFile, mut describe_cmd: Comm describe_cmd .assert() .success() - .stdout(contains("Package contains 0 module(s) and 0 extension(s)")); + .stdout(contains("Package contains 0 modules and 0 extensions")); } #[rstest] @@ -213,7 +213,7 @@ fn test_describe_output_redirection(empty_package_file: NamedTempFile, mut descr output_file.path(), ]); describe_cmd.assert().success(); - output_file.assert(contains("Package contains 0 module(s) and 0 extension(s)")); + output_file.assert(contains("Package contains 0 modules and 0 extensions")); } #[rstest] From f2332d6333d14453fb45aefac79f67d1d69260cc Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 29 Oct 2025 15:19:03 +0000 Subject: [PATCH 5/6] Update hugr-cli/src/lib.rs Co-authored-by: Alec Edgington <54802828+cqc-alec@users.noreply.github.com> --- hugr-cli/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hugr-cli/src/lib.rs b/hugr-cli/src/lib.rs index 688d277ee4..1abfff2f45 100644 --- a/hugr-cli/src/lib.rs +++ b/hugr-cli/src/lib.rs @@ -63,9 +63,9 @@ pub enum CliCommand { External(Vec), /// Describe the contents of a HUGR package. - /// - /// If an error occurs during loading partial descriptions are printed, - /// for example if the first module is loaded and the second fails the + /// + /// If an error occurs during loading partial descriptions are printed. + /// For example if the first module is loaded and the second fails then /// only the first module will be described. Describe(describe::DescribeArgs), } From e555db58ce1666dffd50086df5c0dbcb9052df6a Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Fri, 31 Oct 2025 13:58:09 +0000 Subject: [PATCH 6/6] add schema output --- Cargo.lock | 26 ++++ Cargo.toml | 1 + hugr-cli/Cargo.toml | 2 + hugr-cli/src/describe.rs | 22 +++- hugr-cli/tests/describe.rs | 165 ++++++++++++++++++++++++++ hugr-core/Cargo.toml | 1 + hugr-core/src/envelope/description.rs | 34 ++++-- 7 files changed, 238 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c10b48d0a5..2cc7e5cc1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1203,6 +1203,7 @@ dependencies = [ "hugr", "predicates", "rstest", + "schemars 1.0.4", "serde", "serde_json", "tabled", @@ -1241,6 +1242,7 @@ dependencies = [ "relrc", "rstest", "rustc-hash", + "schemars 1.0.4", "semver", "serde", "serde_json", @@ -2725,10 +2727,23 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2775,6 +2790,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.145" diff --git a/Cargo.toml b/Cargo.toml index 0bbed8f961..609c0320d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ pretty_assertions = "1.4.1" zstd = "0.13.2" relrc = "0.5.0" wyhash = "0.6.0" +schemars = "1.0.4" # These public dependencies usually require breaking changes downstream, so we # try to be as permissive as possible. diff --git a/hugr-cli/Cargo.toml b/hugr-cli/Cargo.toml index 286b9b869f..8204e8a973 100644 --- a/hugr-cli/Cargo.toml +++ b/hugr-cli/Cargo.toml @@ -28,6 +28,8 @@ thiserror.workspace = true tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["fmt"] } tabled = "0.20.0" +schemars = { workspace = true, features = ["derive"] } + [lints] workspace = true diff --git a/hugr-cli/src/describe.rs b/hugr-cli/src/describe.rs index 934ee9f138..017e93505c 100644 --- a/hugr-cli/src/describe.rs +++ b/hugr-cli/src/describe.rs @@ -1,6 +1,4 @@ //! Describe the contents of HUGR packages. -use std::io::Write; - use crate::hugr_io::HugrInputArgs; use anyhow::Result; use clap::Parser; @@ -10,6 +8,7 @@ use hugr::envelope::ReadError; use hugr::envelope::description::{ExtensionDesc, ModuleDesc, PackageDesc}; use hugr::extension::Version; use hugr::package::Package; +use std::io::Write; use tabled::Tabled; use tabled::derive::display; @@ -31,10 +30,15 @@ pub struct DescribeArgs { /// Configure module description pub module_args: ModuleArgs, - #[arg(long, default_value = "false")] + #[arg(long, default_value = "false", help_heading = "JSON")] /// Output in json format pub json: bool, + #[arg(long, default_value = "false", help_heading = "JSON")] + /// Output JSON schema for the description format. + /// Can't be combined with --json. + pub json_schema: bool, + /// Output file. Use '-' for stdout. #[clap(short, long, value_parser, default_value = "-")] pub output: Output, @@ -71,6 +75,12 @@ impl ModuleArgs { impl DescribeArgs { /// Load and describe the HUGR package. pub fn run_describe(&mut self) -> Result<()> { + if self.json_schema { + let schema = schemars::schema_for!(PackageDescriptionJson); + let schema_json = serde_json::to_string_pretty(&schema)?; + writeln!(self.output, "{schema_json}")?; + return Ok(()); + } let (mut desc, res) = match self.input_args.get_described_package() { Ok((desc, pkg)) => (desc, Ok(pkg)), Err(crate::CliError::ReadEnvelope(ReadError::Payload { @@ -143,7 +153,7 @@ impl DescribeArgs { fn output_json(&mut self, package_desc: PackageDesc, res: &Result) -> Result<()> { let err_str = res.as_ref().err().map(|e| format!("{e:?}")); - let json_desc = JsonDescription { + let json_desc = PackageDescriptionJson { package_desc, error: err_str, }; @@ -173,8 +183,8 @@ impl DescribeArgs { } } -#[derive(serde::Serialize)] -struct JsonDescription { +#[derive(serde::Serialize, schemars::JsonSchema)] +struct PackageDescriptionJson { #[serde(flatten)] package_desc: PackageDesc, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/hugr-cli/tests/describe.rs b/hugr-cli/tests/describe.rs index 30f865f5ca..8b07477523 100644 --- a/hugr-cli/tests/describe.rs +++ b/hugr-cli/tests/describe.rs @@ -274,3 +274,168 @@ fn test_describe_invalid_package_json(invalid_package: Vec, mut describe_cmd .stdout(contains("\"error\": \"Error reading a HUGR model payload")) // error included in JSON .stderr(contains("Error reading a HUGR model payload")); } + +#[rstest] +fn test_schema(mut describe_cmd: Command) { + describe_cmd.arg("--json-schema"); + let output = describe_cmd.assert().success().get_output().stdout.clone(); + let schema: Value = serde_json::from_slice(&output).unwrap(); + let expected = json!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageDescriptionJson", + "description": "High-level description of a HUGR package.", + "type": "object", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "header": { + "description": "Envelope header information.", + "type": "string" + }, + "modules": { + "description": "Description of the modules in the package.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/ModuleDesc" + }, + { + "type": "null" + } + ] + } + }, + "packaged_extensions": { + "description": "Description of the extensions in the package.", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/ExtensionDesc" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "header", + "modules" + ], + "$defs": { + "Entrypoint": { + "description": "Description of the entrypoint of a module.", + "type": "object", + "properties": { + "node": { + "description": "Node id of the entrypoint.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "optype": { + "description": "Operation type of the entrypoint node.", + "type": "string" + } + }, + "required": [ + "node", + "optype" + ] + }, + "ExtensionDesc": { + "description": "High level description of an extension.", + "type": "object", + "properties": { + "name": { + "description": "Name of the extension.", + "type": "string" + }, + "version": { + "description": "Version of the extension.", + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "ModuleDesc": { + "description": "High-level description of a module in a HUGR package.", + "type": "object", + "properties": { + "entrypoint": { + "description": "The entrypoint node and the corresponding operation type.", + "anyOf": [ + { + "$ref": "#/$defs/Entrypoint" + }, + { + "type": "null" + } + ] + }, + "generator": { + "description": "Generator specified in the module metadata.", + "type": [ + "string", + "null" + ] + }, + "num_nodes": { + "description": "Number of nodes in the module.", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0 + }, + "public_symbols": { + "description": "Public symbols defined in the module.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "used_extensions_generator": { + "description": "Generator specified used extensions in the module metadata.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ExtensionDesc" + } + }, + "used_extensions_resolved": { + "description": "Extensions used in the module computed while resolving, expected to be a subset of `used_extensions_generator`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ExtensionDesc" + } + } + } + } + } + } + + ); + + assert_eq!(schema, expected); +} diff --git a/hugr-core/Cargo.toml b/hugr-core/Cargo.toml index 904b92765a..862f248079 100644 --- a/hugr-core/Cargo.toml +++ b/hugr-core/Cargo.toml @@ -65,6 +65,7 @@ base64.workspace = true relrc = { workspace = true, features = ["petgraph", "serde"] } smallvec = { workspace = true } tracing = "0.1.41" +schemars = { workspace = true, features = ["derive"] } [dev-dependencies] rstest = { workspace = true } diff --git a/hugr-core/src/envelope/description.rs b/hugr-core/src/envelope/description.rs index b7ada06bdb..d744ab2f79 100644 --- a/hugr-core/src/envelope/description.rs +++ b/hugr-core/src/envelope/description.rs @@ -1,12 +1,11 @@ //! Description of the contents of a HUGR envelope used for debugging and error reporting. -use itertools::Itertools; -use semver::Version; - use crate::{ HugrView, Node, envelope::{EnvelopeHeader, USED_EXTENSIONS_KEY}, ops::{DataflowOpTrait, OpType}, }; +use itertools::Itertools; +use semver::Version; type OptionVec = Vec>; fn set_option_vec_len(vec: &mut OptionVec, n: usize) { @@ -28,15 +27,17 @@ fn extend_option_vec(vec: &mut Option>, items: impl IntoIterato } /// High-level description of a HUGR package. -#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, schemars::JsonSchema)] pub struct PackageDesc { /// Envelope header information. #[serde(serialize_with = "header_serialize")] + #[schemars(with = "String")] pub header: EnvelopeHeader, /// Description of the modules in the package. pub modules: OptionVec, /// Description of the extensions in the package. #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub packaged_extensions: OptionVec, } @@ -117,12 +118,21 @@ impl PackageDesc { } /// High level description of an extension. -#[derive(derive_more::Display, Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +#[derive( + derive_more::Display, + Debug, + Clone, + PartialEq, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, +)] #[display("Extension {name} v{version}")] pub struct ExtensionDesc { /// Name of the extension. pub name: String, /// Version of the extension. + #[schemars(with = "String")] pub version: Version, } @@ -146,11 +156,13 @@ impl> From<&E> for ExtensionDesc { } } -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] /// Description of the entrypoint of a module. pub struct Entrypoint { /// Node id of the entrypoint. + #[schemars(with = "u32")] pub node: Node, + #[schemars(with = "String")] #[serde(serialize_with = "op_serialize")] /// Operation type of the entrypoint node. pub optype: OpType, @@ -185,26 +197,34 @@ where serializer.serialize_str(op_string(op_type).as_str()) } -#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] /// High-level description of a module in a HUGR package. pub struct ModuleDesc { /// Number of nodes in the module. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub num_nodes: Option, /// The entrypoint node and the corresponding operation type. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub entrypoint: Option, /// Extensions used in the module computed while resolving, expected to be a subset of `used_extensions_generator`. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub used_extensions_resolved: Option>, /// Generator specified in the module metadata. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub generator: Option, /// Generator specified used extensions in the module metadata. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub used_extensions_generator: Option>, /// Public symbols defined in the module. #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] pub public_symbols: Option>, }