use std::io::{IsTerminal as _, Write as _};
use std::process::ExitCode;
use clap::ValueEnum;
use clap::error::ErrorKind;
use crate::Commands;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Pretty,
Json,
}
#[must_use]
pub fn resolve_format(explicit: Option<Format>, json_flag: bool) -> Format {
if let Some(f) = explicit {
return f;
}
if json_flag {
return Format::Json;
}
if std::io::stderr().is_terminal() {
Format::Pretty
} else {
Format::Json
}
}
#[must_use]
pub fn detect_format_from_argv() -> Format {
let mut args = std::env::args_os().skip(1);
while let Some(arg) = args.next() {
let Some(arg) = arg.to_str() else { continue };
if arg == "-J" || arg == "--json" {
return Format::Json;
}
if let Some(value) = arg.strip_prefix("--format=") {
return parse_format_value(value);
}
if arg == "--format"
&& let Some(value) = args.next().and_then(|v| v.into_string().ok())
{
return parse_format_value(&value);
}
}
resolve_format(None, false)
}
fn parse_format_value(raw: &str) -> Format {
match raw.to_ascii_lowercase().as_str() {
"json" => Format::Json,
"pretty" => Format::Pretty,
_ => resolve_format(None, false),
}
}
#[must_use]
pub const fn command_name(command: &Commands) -> &'static str {
match command {
Commands::DateUpdated => "date-updated",
Commands::BreedGroups => "breed-groups",
Commands::Statuses => "statuses",
Commands::TraitRanges { .. } => "trait-ranges",
Commands::Search { .. } => "search",
Commands::Details { .. } => "details",
Commands::Lineage { .. } => "lineage",
Commands::Progeny { .. } => "progeny",
Commands::Profile { .. } => "profile",
Commands::Compare { .. } => "compare",
Commands::Completions { .. } => "completions",
Commands::ManPages { .. } => "man-pages",
Commands::Mcp { .. } => "mcp",
}
}
#[must_use]
pub fn clap_error_message(err: &clap::Error) -> String {
err.to_string()
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("argument parsing failed")
.trim_start_matches("error: ")
.to_owned()
}
#[must_use]
pub fn is_clap_display(err: &clap::Error) -> bool {
matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
)
}
#[must_use]
pub fn render_and_exit(err: nsip::Error, command: &str, format: Format) -> ExitCode {
let exit = u8::try_from(err.exit_code()).unwrap_or(1);
match format {
Format::Pretty => render_pretty(err),
Format::Json => render_json(&err, command),
}
ExitCode::from(exit)
}
#[allow(
clippy::print_stderr,
reason = "renderer is the sanctioned stderr writer"
)]
fn render_pretty(err: nsip::Error) {
let report = miette::Report::new(err);
eprintln!("{report:?}");
}
#[allow(
clippy::print_stderr,
reason = "renderer is the sanctioned stderr writer"
)]
fn render_json(err: &nsip::Error, command: &str) {
let pd = err.to_problem_details(command);
if let Ok(s) = serde_json::to_string(&pd) {
let mut stderr = std::io::stderr().lock();
if writeln!(stderr, "{s}").is_err() {
eprintln!("{s}");
}
} else {
let fallback = format!(
"{{\"type\":\"{type_uri}\",\"title\":\"{title}\",\"status\":{status},\"exit_code\":{exit_code},\"detail\":\"serialization failed\"}}",
type_uri = err.type_uri(),
title = err.title(),
status = err.status_code(),
exit_code = err.exit_code(),
);
eprintln!("{fallback}");
}
}
#[cfg(test)]
mod tests {
use std::process::ExitCode;
use super::*;
#[test]
fn resolve_format_precedence() {
assert_eq!(resolve_format(Some(Format::Pretty), true), Format::Pretty);
assert_eq!(resolve_format(Some(Format::Json), false), Format::Json);
assert_eq!(resolve_format(None, true), Format::Json);
assert_eq!(resolve_format(None, false), Format::Json);
}
#[test]
fn parse_format_value_cases() {
assert_eq!(parse_format_value("json"), Format::Json);
assert_eq!(parse_format_value("JSON"), Format::Json);
assert_eq!(parse_format_value("pretty"), Format::Pretty);
assert_eq!(parse_format_value("Pretty"), Format::Pretty);
assert_eq!(parse_format_value("nonsense"), Format::Json);
}
#[test]
fn detect_format_from_argv_no_flag() {
assert_eq!(detect_format_from_argv(), Format::Json);
}
#[test]
fn command_name_maps_variants() {
assert_eq!(command_name(&Commands::DateUpdated), "date-updated");
assert_eq!(command_name(&Commands::BreedGroups), "breed-groups");
assert_eq!(command_name(&Commands::Statuses), "statuses");
assert_eq!(
command_name(&Commands::TraitRanges { breed_id: 1 }),
"trait-ranges"
);
assert_eq!(
command_name(&Commands::Details {
search_string: "x".into(),
}),
"details"
);
assert_eq!(
command_name(&Commands::Lineage { lpn_id: "x".into() }),
"lineage"
);
assert_eq!(
command_name(&Commands::Progeny {
lpn_id: "x".into(),
page: 1,
page_size: 10,
}),
"progeny"
);
assert_eq!(
command_name(&Commands::Profile { lpn_id: "x".into() }),
"profile"
);
assert_eq!(
command_name(&Commands::Compare {
lpn_ids: vec!["a".into()],
traits: None,
}),
"compare"
);
assert_eq!(
command_name(&Commands::Completions {
shell: clap_complete::Shell::Bash,
}),
"completions"
);
assert_eq!(
command_name(&Commands::ManPages { out_dir: None }),
"man-pages"
);
assert_eq!(
command_name(&Commands::Mcp {
transport: "stdio".into(),
host: "127.0.0.1".into(),
port: 0,
tools: None,
auth: false,
}),
"mcp"
);
assert_eq!(
command_name(&Commands::Search {
breed_id: None,
breed_group_id: None,
status: None,
gender: None,
born_after: None,
born_before: None,
proven_only: false,
flock_id: None,
sort_by: None,
reverse: false,
page: 1,
page_size: 10,
}),
"search"
);
}
#[test]
fn clap_error_message_strips_prefix() {
let err = clap::Error::raw(ErrorKind::InvalidValue, "error: bad value\nmore detail\n");
assert_eq!(clap_error_message(&err), "bad value");
}
#[test]
fn is_clap_display_only_help_and_version() {
assert!(is_clap_display(&clap::Error::raw(
ErrorKind::DisplayHelp,
"h"
)));
assert!(is_clap_display(&clap::Error::raw(
ErrorKind::DisplayVersion,
"v"
)));
assert!(!is_clap_display(&clap::Error::raw(
ErrorKind::InvalidValue,
"x"
)));
assert!(!is_clap_display(&clap::Error::raw(
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
"x"
)));
}
#[test]
fn render_and_exit_returns_variant_exit_code() {
let json_code = render_and_exit(nsip::Error::api(503, "down"), "test", Format::Json);
let pretty_code = render_and_exit(nsip::Error::validation("bad"), "test", Format::Pretty);
assert_eq!(
format!("{json_code:?}"),
format!("{:?}", ExitCode::from(75))
);
assert_eq!(
format!("{pretty_code:?}"),
format!("{:?}", ExitCode::from(1))
);
}
}