From ac385616f7c88722ed9a144e20bbff6c7554c1a1 Mon Sep 17 00:00:00 2001 From: "David E. Wheeler" Date: Thu, 1 Aug 2024 19:23:33 -0400 Subject: [PATCH] Implement pgxn_meta CLI Keep it simple right now, mimicking [validate_pgxn_meta], although the `--man` option currently doesn't do much. Use lexopt for argument parsing, since it's super simple like this use case. Avoid [std::process::exit] and set a boolean to return [std::process::ExitCode] when the app should exit without processing a file. This makes it easier to test things, since the functions to print the usage statement or version don't exit. Include tests that cover every bit of functionality except for the `main()` function. Add `corpus/invalid.json` to properly test validation failure. [validate_pgxn_meta]: https://metacpan.org/pod/validate_pgxn_meta [std::process::exit]: https://doc.rust-lang.org/stable/std/process/fn.exit.html [std::process::ExitCode]: https://doc.rust-lang.org/stable/std/process/struct.ExitCode.html --- Cargo.lock | 7 + Cargo.toml | 1 + corpus/invalid.json | 20 +++ src/main.rs | 305 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 corpus/invalid.json create mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 80556b9..67c7cb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "lexopt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" + [[package]] name = "libc" version = "0.2.155" @@ -193,6 +199,7 @@ name = "pgxn_meta" version = "0.1.0" dependencies = [ "boon", + "lexopt", "relative-path", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4df1d37..798a9e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ exclude = [ ".github", ".gitattributes", "target", ".vscode", ".gitignore" ] [dependencies] boon = "0.6" +lexopt = "0.3.0" relative-path = "1.9.3" serde = { version = "1", features = ["derive"] } serde_json = "1.0" diff --git a/corpus/invalid.json b/corpus/invalid.json new file mode 100644 index 0000000..0cd610c --- /dev/null +++ b/corpus/invalid.json @@ -0,0 +1,20 @@ +{ + "name": "pair", + "abstract": "A key/value pair data type", + "maintainers": [ + { + "name": "David E. Wheeler", + "email": "david@justatheory.com" + } + ], + "license": "PostgreSQL", + "contents": { + "extensions": { + "pair": { + "sql": "sql/pair.sql", + "control": "pair.control" + } + } + }, + "meta-spec": { "version": "2.0.0" } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c7a1035 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,305 @@ +use std::{ + env, + error::Error, + ffi::OsString, + fs::File, + io::{self, Write}, + process::ExitCode, +}; + +use pgxn_meta::Validator; +use serde_json::Value; + +// Minimal main function; logical is all in run. +fn main() -> Result> { + run(io::stdout(), env::args_os().collect::>()) +} + +// Run the validator. Output will be sent to `out` and options will be parsed +// from `args`. +fn run(mut out: impl Write, args: I) -> Result> +where + I: IntoIterator, + I::Item: Into, +{ + let res = parse_args(&mut out, args)?; + if !res.exit { + // parse_args() doesn't need to exit, so do the thing. + validate(&res.file)?; + writeln!(out, "{} is OK", &res.file)?; + } + + // If we got here, wer were successful. + Ok(ExitCode::SUCCESS) +} + +// process_args() parses argument into this struct. +struct Args { + exit: bool, + file: String, +} + +// The default name of the file to validate. +const META_FILE: &str = "META.json"; + +// Parses the arguments in `args` and returns Args. Output is sent to `out`. +// If `Args.exit` is true, the caller should do no more processing. +fn parse_args(out: &mut impl Write, args: I) -> Result> +where + I: IntoIterator, + I::Item: Into, +{ + use lexopt::prelude::*; + let mut res = Args { + exit: false, + file: String::from(META_FILE), + }; + let mut parser = lexopt::Parser::from_iter(args); + + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => { + usage(out, &parser)?; + res.exit = true + } + Short('v') | Long("version") => { + version(out, &parser)?; + res.exit = true + } + Short('m') | Long("man") => { + docs(out)?; + res.exit = true + } + // Last one wins. Raise an error instead? + Value(val) => res.file = val.string()?, + _ => return Err(Box::new(arg.unexpected())), + } + } + + Ok(res) +} + +// Validates `file`. Panics on validation failure. +fn validate(file: &str) -> Result<(), Box> { + let meta: Value = serde_json::from_reader(File::open(file)?)?; + let mut v = Validator::new(); + if let Err(e) = v.validate(&meta) { + panic!("{file} {e}"); + }; + Ok(()) +} + +// Returns the binary name from the argument parser and falls back on the name +// determined at compile time. +macro_rules! bn { + ($x:expr) => {{ + $x.bin_name().unwrap_or(env!("CARGO_BIN_NAME")) + }}; +} + +// Outputs a usage statement. +fn usage(out: &mut impl Write, p: &lexopt::Parser) -> Result<(), Box> { + writeln!( + out, + "Usage: {} [--help | h] [--version | -v] []\n\n\ + Options:\n\ + \x20 -h --help Print this usage statement and exit\n\ + \x20 -v --version Print the version number and exit", + bn!(p), + )?; + Ok(()) +} + +// Outputs a version statement. +fn version(out: &mut impl Write, p: &lexopt::Parser) -> Result<(), Box> { + writeln!(out, "{} {}", bn!(p), env!("CARGO_PKG_VERSION"))?; + Ok(()) +} + +// Outputs docs. Or will eventually. +fn docs(out: &mut impl Write) -> Result<(), Box> { + writeln!(out, "Docs")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use core::panic; + use std::{ffi::OsStr, path::Path, str}; + + struct TC<'a> { + name: &'a str, + args: &'a [&'a str], + exit: bool, + file: &'a str, + out: &'a str, + } + + #[test] + fn test_parse_args() -> Result<(), Box> { + for tc in [ + TC { + name: "no args", + args: &["meta"], + exit: false, + file: META_FILE, + out: "", + }, + TC { + name: "short help", + args: &["meta", "-h"], + exit: true, + file: META_FILE, + out: "Usage: meta [--help | h] [--version | -v] []\n\n\ + Options:\n\ + \x20 -h --help Print this usage statement and exit\n\ + \x20 -v --version Print the version number and exit\n", + }, + TC { + name: "long help", + args: &["meta", "--help"], + exit: true, + file: META_FILE, + out: "Usage: meta [--help | h] [--version | -v] []\n\n\ + Options:\n\ + \x20 -h --help Print this usage statement and exit\n\ + \x20 -v --version Print the version number and exit\n", + }, + TC { + name: "short version", + args: &["meta", "-v"], + exit: true, + file: META_FILE, + out: concat!("meta ", env!("CARGO_PKG_VERSION"), "\n"), + }, + TC { + name: "long version", + args: &["meta", "--version"], + exit: true, + file: META_FILE, + out: concat!("meta ", env!("CARGO_PKG_VERSION"), "\n"), + }, + TC { + name: "short man", + args: &["meta", "-m"], + exit: true, + file: META_FILE, + out: "Docs\n", + }, + TC { + name: "long man", + args: &["meta", "--man"], + exit: true, + file: META_FILE, + out: "Docs\n", + }, + TC { + name: "file name", + args: &["meta", "hello.json"], + exit: false, + file: "hello.json", + out: "", + }, + TC { + name: "multiple values", + args: &["meta", "hello.json", "hi.json"], + exit: false, + file: "hi.json", + out: "", + }, + ] { + let mut file: Vec = Vec::new(); + match parse_args(&mut file, tc.args) { + Err(e) => panic!("test {} failed: {e}", tc.name), + Ok(res) => { + assert_eq!(res.exit, tc.exit); + assert_eq!(res.file, tc.file); + assert_eq!(str::from_utf8(&file)?, tc.out); + } + } + } + + // Make sure we get an error for an unknown option. + let mut file: Vec = Vec::new(); + match parse_args(&mut file, ["hi", "-x"]) { + Ok(_) => panic!("Should have failed on -x but did not"), + Err(e) => { + assert_eq!(e.to_string(), "invalid option '-x'"); + } + } + + Ok(()) + } + + #[test] + fn test_run() -> Result<(), Box> { + let meta = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("corpus") + .join("v2") + .join("minimal.json"); + let ok_output = format!("{} is OK\n", meta.display()); + + struct TC<'a> { + name: &'a str, + args: &'a [&'a OsStr], + out: &'a str, + // code: u8, + } + + for tc in [ + TC { + name: "version", + args: &[OsStr::new("xyz"), OsStr::new("-v")], + out: concat!("xyz ", env!("CARGO_PKG_VERSION"), "\n"), + }, + TC { + name: "pass file", + args: &[OsStr::new("xyz"), meta.as_os_str()], + out: &ok_output, + }, + ] { + let mut file: Vec = Vec::new(); + match run(&mut file, tc.args) { + Err(e) => panic!("test {:} failed: {e}", tc.name), + Ok(_) => { + assert_eq!(str::from_utf8(&file)?, tc.out); + } + } + } + + Ok(()) + } + + #[test] + fn test_validate() -> Result<(), Box> { + // Success first. + let meta = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("corpus") + .join("v2") + .join("minimal.json"); + + match validate(meta.as_os_str().to_str().unwrap()) { + Ok(_) => (), + Err(e) => panic!("Validation failed: {e}"), + } + + // Invalid next. + let meta = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("corpus") + .join("invalid.json"); + + match std::panic::catch_unwind(|| validate(meta.as_os_str().to_str().unwrap())) { + Ok(_) => panic!("Should have failed on invalid.json but did not"), + Err(e) => { + if let Ok(msg) = e.downcast::() { + assert!(msg.contains(" missing properties 'version")); + } else { + panic!("Unexpected panic error"); + } + } + } + + Ok(()) + } +}