From 40185b3570f732d9ac73721a60c22b1e066b003f Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 22 May 2024 11:34:06 +0100 Subject: [PATCH 1/4] feat: cli with optional features --- hugr/Cargo.toml | 14 +++++++++++--- hugr/src/cli.rs | 38 ++++++++++++++++++++++++++++++++++++++ hugr/src/lib.rs | 3 +++ hugr/src/main.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 hugr/src/cli.rs create mode 100644 hugr/src/main.rs diff --git a/hugr/Cargo.toml b/hugr/Cargo.toml index 03b643550..bec1dfe13 100644 --- a/hugr/Cargo.toml +++ b/hugr/Cargo.toml @@ -23,6 +23,7 @@ path = "src/lib.rs" [features] extension_inference = [] +cli = ["dep:clap", "dep:clap-stdin"] [dependencies] portgraph = { workspace = true, features = ["serde", "petgraph"] } @@ -52,6 +53,8 @@ delegate = "0.12.0" paste = "1.0" strum = "0.26.1" strum_macros = "0.26.1" +clap = { version = "4.5.4", features = ["derive"], optional = true } +clap-stdin = { version = "0.4.0", optional = true } [dev-dependencies] criterion = { version = "0.5.1", features = ["html_reports"] } @@ -61,10 +64,15 @@ urlencoding = "2.1.2" cool_asserts = "2.0.3" insta = { workspace = true, features = ["yaml"] } jsonschema = "0.18.0" -proptest = { version = "1.4.0" } -proptest-derive = { version = "0.4.0"} -regex-syntax = { version = "0.8.3"} +proptest = { version = "1.4.0" } +proptest-derive = { version = "0.4.0" } +regex-syntax = { version = "0.8.3" } [[bench]] name = "bench_main" harness = false + + +[[bin]] +name = "hugr" +required-features = ["cli"] diff --git a/hugr/src/cli.rs b/hugr/src/cli.rs new file mode 100644 index 000000000..774c419ff --- /dev/null +++ b/hugr/src/cli.rs @@ -0,0 +1,38 @@ +//! Standard command line tools, used by the hugr binary. + +use clap::Parser; +use clap_stdin::FileOrStdin; + +use crate::{extension::ExtensionRegistry, Hugr, HugrView}; +/// Validate and visualise a HUGR file. +#[derive(Parser, Debug)] +#[clap(version = "1.0", long_about = None)] +#[clap(about = "Validate a HUGR.")] +struct CmdLineArgs { + input: FileOrStdin, + /// Visualise with mermaid. + #[arg(short, long, value_name = "MERMAID", help = "Visualise with mermaid.")] + mermaid: bool, + + /// Skip validation. + #[arg(short, long, help = "Skip validation.")] + no_validate: bool, + // TODO YAML extensions +} + +/// Run the HUGR cli and validate against an extension registry. +pub fn run(registry: &ExtensionRegistry) -> Result<(), Box> { + let opts = CmdLineArgs::parse(); + + let mut hugr: Hugr = serde_json::from_reader(opts.input.into_reader()?)?; + if opts.mermaid { + println!("{}", hugr.mermaid_string()); + } + + if !opts.no_validate { + hugr.update_validate(registry)?; + + println!("HUGR valid!"); + } + Ok(()) +} diff --git a/hugr/src/lib.rs b/hugr/src/lib.rs index 7d1dd2499..7c1cdca5f 100644 --- a/hugr/src/lib.rs +++ b/hugr/src/lib.rs @@ -157,5 +157,8 @@ pub use crate::core::{ pub use crate::extension::Extension; pub use crate::hugr::{Hugr, HugrView, SimpleReplacement}; +#[cfg(feature = "cli")] +pub mod cli; + #[cfg(test)] pub mod proptest; diff --git a/hugr/src/main.rs b/hugr/src/main.rs new file mode 100644 index 000000000..e5de6d160 --- /dev/null +++ b/hugr/src/main.rs @@ -0,0 +1,28 @@ +//! Validate serialized HUGR on the command line + +use hugr::std_extensions::arithmetic::{ + conversions::EXTENSION as CONVERSIONS_EXTENSION, float_ops::EXTENSION as FLOAT_OPS_EXTENSION, + float_types::EXTENSION as FLOAT_TYPES_EXTENSION, int_ops::EXTENSION as INT_OPS_EXTENSION, + int_types::EXTENSION as INT_TYPES_EXTENSION, +}; +use hugr::std_extensions::logic::EXTENSION as LOGICS_EXTENSION; + +use hugr::extension::{ExtensionRegistry, PRELUDE}; + +use hugr::cli::run; + +fn main() -> Result<(), Box> { + // validate with all std extensions + let reg = ExtensionRegistry::try_new([ + PRELUDE.to_owned(), + INT_OPS_EXTENSION.to_owned(), + INT_TYPES_EXTENSION.to_owned(), + CONVERSIONS_EXTENSION.to_owned(), + FLOAT_OPS_EXTENSION.to_owned(), + FLOAT_TYPES_EXTENSION.to_owned(), + LOGICS_EXTENSION.to_owned(), + ]) + .unwrap(); + + run(®) +} From 6b32fab166d7b5a823c344c8aeb98b519ade0056 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 22 May 2024 12:22:29 +0100 Subject: [PATCH 2/4] test: add cli integration testing based on https://rust-cli.github.io/book/tutorial/testing.html --- hugr/Cargo.toml | 3 ++ hugr/src/cli.rs | 5 ++- hugr/tests/cli.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 hugr/tests/cli.rs diff --git a/hugr/Cargo.toml b/hugr/Cargo.toml index bec1dfe13..89a5f8949 100644 --- a/hugr/Cargo.toml +++ b/hugr/Cargo.toml @@ -67,6 +67,9 @@ jsonschema = "0.18.0" proptest = { version = "1.4.0" } proptest-derive = { version = "0.4.0" } regex-syntax = { version = "0.8.3" } +assert_cmd = "2.0.14" +predicates = "3.1.0" +assert_fs = "1.1.1" [[bench]] name = "bench_main" diff --git a/hugr/src/cli.rs b/hugr/src/cli.rs index 774c419ff..9e98733ee 100644 --- a/hugr/src/cli.rs +++ b/hugr/src/cli.rs @@ -20,6 +20,9 @@ struct CmdLineArgs { // TODO YAML extensions } +/// String to print when validation is successful. +pub const VALID_PRINT: &str = "HUGR valid!"; + /// Run the HUGR cli and validate against an extension registry. pub fn run(registry: &ExtensionRegistry) -> Result<(), Box> { let opts = CmdLineArgs::parse(); @@ -32,7 +35,7 @@ pub fn run(registry: &ExtensionRegistry) -> Result<(), Box Command { + Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() +} + +#[fixture] +fn test_hugr() -> Hugr { + use hugr::builder::DFGBuilder; + + let df = DFGBuilder::new(FunctionType::new_endo(type_row![BOOL_T])).unwrap(); + let [i] = df.input_wires_arr(); + df.finish_prelude_hugr_with_outputs([i]).unwrap() +} + +#[fixture] +fn test_hugr_string(test_hugr: Hugr) -> String { + serde_json::to_string(&test_hugr).unwrap() +} + +#[fixture] +fn test_hugr_file(test_hugr_string: String) -> NamedTempFile { + // TODO use proptests? + let file = assert_fs::NamedTempFile::new("sample.hugr").unwrap(); + file.write_str(&test_hugr_string).unwrap(); + file +} + +#[rstest] +fn test_doesnt_exist(mut cmd: Command) -> Result<(), Box> { + cmd.arg("foobar"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("No such file or directory")); + + Ok(()) +} + +#[rstest] +fn test_validate( + test_hugr_file: NamedTempFile, + mut cmd: Command, +) -> Result<(), Box> { + cmd.arg(test_hugr_file.path()); + cmd.assert() + .success() + .stdout(predicate::str::contains(VALID_PRINT)); + + Ok(()) +} + +#[rstest] +fn test_stdin( + test_hugr_string: String, + mut cmd: Command, +) -> Result<(), Box> { + cmd.write_stdin(test_hugr_string); + cmd.arg("-"); + + cmd.assert() + .success() + .stdout(predicate::str::contains(VALID_PRINT)); + + Ok(()) +} + +#[rstest] +fn test_mermaid( + test_hugr_file: NamedTempFile, + mut cmd: Command, +) -> Result<(), Box> { + const MERMAID: &str = "graph LR\n subgraph 0 [\"(0) DFG\"]"; + cmd.arg(test_hugr_file.path()); + cmd.arg("--mermaid"); + cmd.arg("--no-validate"); + cmd.assert() + .success() + .stdout(predicate::str::contains(MERMAID)); + + Ok(()) +} From dd0dce2068815ec96b484eb0c4b537a2027bd531 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 22 May 2024 13:41:10 +0100 Subject: [PATCH 3/4] make run a method on cmdlineargs --- hugr/src/cli.rs | 27 +++++++++++++-------------- hugr/src/main.rs | 7 +++++-- hugr/tests/cli.rs | 1 - 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/hugr/src/cli.rs b/hugr/src/cli.rs index 9e98733ee..a0bcc3c11 100644 --- a/hugr/src/cli.rs +++ b/hugr/src/cli.rs @@ -8,12 +8,11 @@ use crate::{extension::ExtensionRegistry, Hugr, HugrView}; #[derive(Parser, Debug)] #[clap(version = "1.0", long_about = None)] #[clap(about = "Validate a HUGR.")] -struct CmdLineArgs { +pub struct CmdLineArgs { input: FileOrStdin, /// Visualise with mermaid. #[arg(short, long, value_name = "MERMAID", help = "Visualise with mermaid.")] mermaid: bool, - /// Skip validation. #[arg(short, long, help = "Skip validation.")] no_validate: bool, @@ -23,19 +22,19 @@ struct CmdLineArgs { /// String to print when validation is successful. pub const VALID_PRINT: &str = "HUGR valid!"; -/// Run the HUGR cli and validate against an extension registry. -pub fn run(registry: &ExtensionRegistry) -> Result<(), Box> { - let opts = CmdLineArgs::parse(); - - let mut hugr: Hugr = serde_json::from_reader(opts.input.into_reader()?)?; - if opts.mermaid { - println!("{}", hugr.mermaid_string()); - } +impl CmdLineArgs { + /// Run the HUGR cli and validate against an extension registry. + pub fn run(&self, registry: &ExtensionRegistry) -> Result<(), Box> { + let mut hugr: Hugr = serde_json::from_reader(self.input.into_reader()?)?; + if self.mermaid { + println!("{}", hugr.mermaid_string()); + } - if !opts.no_validate { - hugr.update_validate(registry)?; + if !self.no_validate { + hugr.update_validate(registry)?; - println!("{}", VALID_PRINT); + println!("{}", VALID_PRINT); + } + Ok(()) } - Ok(()) } diff --git a/hugr/src/main.rs b/hugr/src/main.rs index e5de6d160..2a350fdaf 100644 --- a/hugr/src/main.rs +++ b/hugr/src/main.rs @@ -9,9 +9,12 @@ use hugr::std_extensions::logic::EXTENSION as LOGICS_EXTENSION; use hugr::extension::{ExtensionRegistry, PRELUDE}; -use hugr::cli::run; +use clap::Parser; +use hugr::cli::CmdLineArgs; fn main() -> Result<(), Box> { + let opts = CmdLineArgs::parse(); + // validate with all std extensions let reg = ExtensionRegistry::try_new([ PRELUDE.to_owned(), @@ -24,5 +27,5 @@ fn main() -> Result<(), Box> { ]) .unwrap(); - run(®) + opts.run(®) } diff --git a/hugr/tests/cli.rs b/hugr/tests/cli.rs index 90824d75d..1bc6f74f3 100644 --- a/hugr/tests/cli.rs +++ b/hugr/tests/cli.rs @@ -34,7 +34,6 @@ fn test_hugr_string(test_hugr: Hugr) -> String { #[fixture] fn test_hugr_file(test_hugr_string: String) -> NamedTempFile { - // TODO use proptests? let file = assert_fs::NamedTempFile::new("sample.hugr").unwrap(); file.write_str(&test_hugr_string).unwrap(); file From dc1396b826b3d2d2c8c6e46e7adb2c3c55af4850 Mon Sep 17 00:00:00 2001 From: Seyon Sivarajah Date: Wed, 22 May 2024 14:01:27 +0100 Subject: [PATCH 4/4] add cli error type --- hugr/src/cli.rs | 20 +++++++++++--- hugr/src/main.rs | 7 +++-- hugr/tests/cli.rs | 69 +++++++++++++++++++++++------------------------ 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/hugr/src/cli.rs b/hugr/src/cli.rs index a0bcc3c11..a58278188 100644 --- a/hugr/src/cli.rs +++ b/hugr/src/cli.rs @@ -1,9 +1,9 @@ //! Standard command line tools, used by the hugr binary. +use crate::{extension::ExtensionRegistry, Hugr, HugrView}; use clap::Parser; use clap_stdin::FileOrStdin; - -use crate::{extension::ExtensionRegistry, Hugr, HugrView}; +use thiserror::Error; /// Validate and visualise a HUGR file. #[derive(Parser, Debug)] #[clap(version = "1.0", long_about = None)] @@ -19,12 +19,26 @@ pub struct CmdLineArgs { // TODO YAML extensions } +/// Error type for the CLI. +#[derive(Error, Debug)] +pub enum CliError { + /// Error reading input. + #[error("Error reading input: {0}")] + Input(#[from] clap_stdin::StdinError), + /// Error parsing input. + #[error("Error parsing input: {0}")] + Parse(#[from] serde_json::Error), + /// Error validating HUGR. + #[error("Error validating HUGR: {0}")] + Validate(#[from] crate::hugr::ValidationError), +} + /// String to print when validation is successful. pub const VALID_PRINT: &str = "HUGR valid!"; impl CmdLineArgs { /// Run the HUGR cli and validate against an extension registry. - pub fn run(&self, registry: &ExtensionRegistry) -> Result<(), Box> { + pub fn run(&self, registry: &ExtensionRegistry) -> Result<(), CliError> { let mut hugr: Hugr = serde_json::from_reader(self.input.into_reader()?)?; if self.mermaid { println!("{}", hugr.mermaid_string()); diff --git a/hugr/src/main.rs b/hugr/src/main.rs index 2a350fdaf..7055446fa 100644 --- a/hugr/src/main.rs +++ b/hugr/src/main.rs @@ -12,7 +12,7 @@ use hugr::extension::{ExtensionRegistry, PRELUDE}; use clap::Parser; use hugr::cli::CmdLineArgs; -fn main() -> Result<(), Box> { +fn main() { let opts = CmdLineArgs::parse(); // validate with all std extensions @@ -27,5 +27,8 @@ fn main() -> Result<(), Box> { ]) .unwrap(); - opts.run(®) + if let Err(e) = opts.run(®) { + eprintln!("{}", e); + std::process::exit(1); + } } diff --git a/hugr/tests/cli.rs b/hugr/tests/cli.rs index 1bc6f74f3..601762081 100644 --- a/hugr/tests/cli.rs +++ b/hugr/tests/cli.rs @@ -1,17 +1,17 @@ #![cfg(feature = "cli")] - use assert_cmd::Command; use assert_fs::{fixture::FileWriteStr, NamedTempFile}; use hugr::{ - builder::{Dataflow, DataflowHugr}, - extension::prelude::BOOL_T, + builder::{Container, Dataflow, DataflowHugr}, + extension::prelude::{BOOL_T, QB_T}, type_row, types::FunctionType, Hugr, }; -use predicates::prelude::*; +use predicates::{prelude::*, str::contains}; use rstest::{fixture, rstest}; +use hugr::builder::DFGBuilder; use hugr::cli::VALID_PRINT; #[fixture] fn cmd() -> Command { @@ -20,8 +20,6 @@ fn cmd() -> Command { #[fixture] fn test_hugr() -> Hugr { - use hugr::builder::DFGBuilder; - let df = DFGBuilder::new(FunctionType::new_endo(type_row![BOOL_T])).unwrap(); let [i] = df.input_wires_arr(); df.finish_prelude_hugr_with_outputs([i]).unwrap() @@ -40,55 +38,56 @@ fn test_hugr_file(test_hugr_string: String) -> NamedTempFile { } #[rstest] -fn test_doesnt_exist(mut cmd: Command) -> Result<(), Box> { +fn test_doesnt_exist(mut cmd: Command) { cmd.arg("foobar"); cmd.assert() .failure() - .stderr(predicate::str::contains("No such file or directory")); - - Ok(()) + .stderr(contains("No such file or directory").and(contains("Error reading input"))); } #[rstest] -fn test_validate( - test_hugr_file: NamedTempFile, - mut cmd: Command, -) -> Result<(), Box> { +fn test_validate(test_hugr_file: NamedTempFile, mut cmd: Command) { cmd.arg(test_hugr_file.path()); - cmd.assert() - .success() - .stdout(predicate::str::contains(VALID_PRINT)); - - Ok(()) + cmd.assert().success().stdout(contains(VALID_PRINT)); } #[rstest] -fn test_stdin( - test_hugr_string: String, - mut cmd: Command, -) -> Result<(), Box> { +fn test_stdin(test_hugr_string: String, mut cmd: Command) { cmd.write_stdin(test_hugr_string); cmd.arg("-"); - cmd.assert() - .success() - .stdout(predicate::str::contains(VALID_PRINT)); - - Ok(()) + cmd.assert().success().stdout(contains(VALID_PRINT)); } #[rstest] -fn test_mermaid( - test_hugr_file: NamedTempFile, - mut cmd: Command, -) -> Result<(), Box> { +fn test_mermaid(test_hugr_file: NamedTempFile, mut cmd: Command) { const MERMAID: &str = "graph LR\n subgraph 0 [\"(0) DFG\"]"; cmd.arg(test_hugr_file.path()); cmd.arg("--mermaid"); cmd.arg("--no-validate"); + cmd.assert().success().stdout(contains(MERMAID)); +} + +#[rstest] +fn test_bad_hugr(mut cmd: Command) { + let df = DFGBuilder::new(FunctionType::new_endo(type_row![QB_T])).unwrap(); + let bad_hugr = df.hugr().clone(); + + let bad_hugr_string = serde_json::to_string(&bad_hugr).unwrap(); + cmd.write_stdin(bad_hugr_string); + cmd.arg("-"); + cmd.assert() - .success() - .stdout(predicate::str::contains(MERMAID)); + .failure() + .stderr(contains("Error validating HUGR").and(contains("unconnected port"))); +} - Ok(()) +#[rstest] +fn test_bad_json(mut cmd: Command) { + cmd.write_stdin(r#"{"foo": "bar"}"#); + cmd.arg("-"); + + cmd.assert() + .failure() + .stderr(contains("Error parsing input")); }