Skip to content

Commit

Permalink
Implement pgxn_meta CLI
Browse files Browse the repository at this point in the history
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
  • Loading branch information
theory committed Aug 1, 2024
1 parent 58ffb17 commit ac38561
Show file tree
Hide file tree
Showing 4 changed files with 333 additions and 0 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions corpus/invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "pair",
"abstract": "A key/value pair data type",
"maintainers": [
{
"name": "David E. Wheeler",
"email": "[email protected]"
}
],
"license": "PostgreSQL",
"contents": {
"extensions": {
"pair": {
"sql": "sql/pair.sql",
"control": "pair.control"
}
}
},
"meta-spec": { "version": "2.0.0" }
}
305 changes: 305 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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<ExitCode, Box<dyn Error>> {
run(io::stdout(), env::args_os().collect::<Vec<_>>())
}

Check warning on line 16 in src/main.rs

View check run for this annotation

Codecov / codecov/patch

src/main.rs#L14-L16

Added lines #L14 - L16 were not covered by tests

// Run the validator. Output will be sent to `out` and options will be parsed
// from `args`.
fn run<I>(mut out: impl Write, args: I) -> Result<ExitCode, Box<dyn Error>>
where
I: IntoIterator,
I::Item: Into<OsString>,
{
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<I>(out: &mut impl Write, args: I) -> Result<Args, Box<dyn Error>>
where
I: IntoIterator,
I::Item: Into<OsString>,
{
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<dyn Error>> {
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<dyn Error>> {
writeln!(
out,
"Usage: {} [--help | h] [--version | -v] [<path>]\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<dyn Error>> {
writeln!(out, "{} {}", bn!(p), env!("CARGO_PKG_VERSION"))?;
Ok(())
}

// Outputs docs. Or will eventually.
fn docs(out: &mut impl Write) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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] [<path>]\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] [<path>]\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<u8> = 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<u8> = 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<dyn Error>> {
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<u8> = 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<dyn Error>> {
// 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::<String>() {
assert!(msg.contains(" missing properties 'version"));
} else {
panic!("Unexpected panic error");
}
}
}

Ok(())
}
}

0 comments on commit ac38561

Please sign in to comment.