forked from Crockan/MercuryToolbox
chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "configsupport"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Shared CLI helpers for standalone Mercury Toolbox config and repo commands."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,614 @@
|
||||
//! Shared CLI helpers for standalone Mercury Toolbox commands.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Controls ANSI color behavior for command output.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ColorChoice {
|
||||
/// Enable color only when output looks interactive.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Never emit ANSI color sequences.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Selects the output surface for a command invocation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
/// Emit compact text.
|
||||
Text,
|
||||
/// Emit compact JSON.
|
||||
Json,
|
||||
/// Emit compact TOON.
|
||||
Toon,
|
||||
}
|
||||
|
||||
/// Shared flags that every standalone command accepts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct CommonArgs {
|
||||
/// Emit JSON instead of text.
|
||||
pub json: bool,
|
||||
/// Explicit structured output format selected by `--format`, `--json`, or `--toon`.
|
||||
pub format: Option<RenderMode>,
|
||||
/// Suppress non-essential status output.
|
||||
pub quiet: bool,
|
||||
/// Control ANSI color output.
|
||||
pub color: ColorChoice,
|
||||
}
|
||||
|
||||
impl CommonArgs {
|
||||
/// Records an explicit output format selection.
|
||||
pub fn set_render_mode(&mut self, render_mode: RenderMode) {
|
||||
self.json = render_mode == RenderMode::Json;
|
||||
self.format = Some(render_mode);
|
||||
}
|
||||
|
||||
/// Returns the output mode implied by the current settings.
|
||||
#[must_use]
|
||||
pub const fn render_mode(self) -> RenderMode {
|
||||
if let Some(format) = self.format {
|
||||
return format;
|
||||
}
|
||||
if self.json {
|
||||
RenderMode::Json
|
||||
} else {
|
||||
RenderMode::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether stdin is attached to an interactive terminal.
|
||||
#[must_use]
|
||||
pub fn stdin_is_terminal(&self) -> bool {
|
||||
io::stdin().is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable process exit codes shared by the standalone commands.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitCode {
|
||||
/// The command succeeded and produced at least one result.
|
||||
Success = 0,
|
||||
/// The command succeeded but produced no matching results.
|
||||
NoResults = 1,
|
||||
/// The invocation was rejected because the input was invalid.
|
||||
UsageError = 2,
|
||||
/// The command hit an operational failure at runtime.
|
||||
RuntimeError = 3,
|
||||
}
|
||||
|
||||
impl ExitCode {
|
||||
/// Converts the enum into a process exit code.
|
||||
#[must_use]
|
||||
pub const fn as_i32(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents user-facing command failures.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CliError {
|
||||
/// The user supplied invalid arguments or malformed input.
|
||||
#[error("{0}")]
|
||||
Usage(String),
|
||||
/// The command failed while reading, probing, or rendering data.
|
||||
#[error("{0}")]
|
||||
Runtime(String),
|
||||
}
|
||||
|
||||
impl CliError {
|
||||
/// Builds a usage error.
|
||||
#[must_use]
|
||||
pub fn usage(message: impl Into<String>) -> Self {
|
||||
Self::Usage(message.into())
|
||||
}
|
||||
|
||||
/// Builds a runtime error.
|
||||
#[must_use]
|
||||
pub fn runtime(message: impl Into<String>) -> Self {
|
||||
Self::Runtime(message.into())
|
||||
}
|
||||
|
||||
/// Returns the exit code associated with the error category.
|
||||
#[must_use]
|
||||
pub const fn exit_code(&self) -> ExitCode {
|
||||
match self {
|
||||
Self::Usage(_) => ExitCode::UsageError,
|
||||
Self::Runtime(_) => ExitCode::RuntimeError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the shared `--color` argument.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when the value is not supported.
|
||||
pub fn parse_color_choice(value: &str) -> Result<ColorChoice, CliError> {
|
||||
match value {
|
||||
"auto" => Ok(ColorChoice::Auto),
|
||||
"never" => Ok(ColorChoice::Never),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid --color value '{other}'; expected auto or never"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the shared `--format` output argument.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when the value is not supported.
|
||||
pub fn parse_format_choice(value: &str) -> Result<RenderMode, CliError> {
|
||||
match value {
|
||||
"text" => Ok(RenderMode::Text),
|
||||
"json" => Ok(RenderMode::Json),
|
||||
"toon" => Ok(RenderMode::Toon),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid --format value '{other}'; expected text, json, or toon"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a JSON value to stdout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
|
||||
pub fn print_json<T>(value: &T) -> Result<(), CliError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let mut stdout = io::stdout().lock();
|
||||
if let Err(error) = serde_json::to_writer(&mut stdout, value) {
|
||||
return match error.io_error_kind() {
|
||||
Some(io::ErrorKind::BrokenPipe) => Ok(()),
|
||||
_ => Err(CliError::runtime(format!(
|
||||
"failed to write stdout: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
stdout_write_result(stdout.write_all(b"\n"))
|
||||
}
|
||||
|
||||
/// Writes a structured value to stdout as JSON or TOON.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
|
||||
pub fn print_structured<T>(value: &T, render_mode: RenderMode) -> Result<(), CliError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
match render_mode {
|
||||
RenderMode::Json => print_json(value),
|
||||
RenderMode::Toon => {
|
||||
let value = serde_json::to_value(value).map_err(|error| {
|
||||
CliError::runtime(format!("failed to serialize structured output: {error}"))
|
||||
})?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
write_toon_value(&mut stdout, &value, 0)?;
|
||||
stdout_write_result(stdout.write_all(b"\n"))
|
||||
}
|
||||
RenderMode::Text => Err(CliError::runtime(
|
||||
"structured text rendering requires command-specific text output",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_toon_value(
|
||||
writer: &mut impl Write,
|
||||
value: &serde_json::Value,
|
||||
depth: usize,
|
||||
) -> Result<(), CliError> {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
match value {
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||
stdout_write_result(writeln!(writer, "{}{}:", " ".repeat(depth), key))?;
|
||||
write_toon_value(writer, value, depth + 1)?;
|
||||
}
|
||||
primitive => {
|
||||
stdout_write_result(writeln!(
|
||||
writer,
|
||||
"{}{}: {}",
|
||||
" ".repeat(depth),
|
||||
key,
|
||||
toon_primitive(primitive)
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(array) => {
|
||||
for value in array {
|
||||
stdout_write_result(writeln!(
|
||||
writer,
|
||||
"{}- {}",
|
||||
" ".repeat(depth),
|
||||
toon_primitive(value)
|
||||
))?;
|
||||
}
|
||||
}
|
||||
primitive => {
|
||||
stdout_write_result(write!(writer, "{}", toon_primitive(primitive)))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stdout_write_result(result: io::Result<()>) -> Result<(), CliError> {
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()),
|
||||
Err(error) => Err(CliError::runtime(format!(
|
||||
"failed to write stdout: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn toon_primitive(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::Null => "null".to_string(),
|
||||
serde_json::Value::Bool(value) => value.to_string(),
|
||||
serde_json::Value::Number(value) => value.to_string(),
|
||||
serde_json::Value::String(value) => value.clone(),
|
||||
serde_json::Value::Array(_) | serde_json::Value::Object(_) => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a single line of text to stdout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when stdout cannot be written.
|
||||
pub fn print_text(text: impl Display) -> Result<(), CliError> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout_write_result(writeln!(stdout, "{text}"))
|
||||
}
|
||||
|
||||
/// Writes a formatted error message to stderr.
|
||||
pub fn print_error(error: &CliError) {
|
||||
let _ = writeln!(io::stderr().lock(), "{error}");
|
||||
}
|
||||
|
||||
/// Writes a compact first-page help card for usage failures.
|
||||
pub fn print_quick_help_error(error: &CliError, help: &str) {
|
||||
let use_color = stderr_supports_color();
|
||||
let mut stderr = io::stderr().lock();
|
||||
if use_color {
|
||||
let _ = writeln!(stderr, "\x1b[31;1merror:\x1b[0m {error}");
|
||||
} else {
|
||||
let _ = writeln!(stderr, "error: {error}");
|
||||
}
|
||||
let _ = writeln!(stderr);
|
||||
let _ = write_quick_help(&mut stderr, help, use_color);
|
||||
}
|
||||
|
||||
fn stderr_supports_color() -> bool {
|
||||
io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
|
||||
}
|
||||
|
||||
fn write_quick_help(mut writer: impl Write, help: &str, use_color: bool) -> io::Result<()> {
|
||||
let summary = first_help_line(help).unwrap_or("Mercury Toolbox command");
|
||||
let command = quick_help_command(help).unwrap_or("mercury");
|
||||
let title = format!("{command} - Mercury Toolbox");
|
||||
|
||||
write_quick_heading(&mut writer, &title, use_color)?;
|
||||
writeln!(writer, " {summary}")?;
|
||||
writeln!(writer)?;
|
||||
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Usage:",
|
||||
&[
|
||||
"Commands:",
|
||||
"Subcommands:",
|
||||
"Options:",
|
||||
"Shared Options:",
|
||||
"Examples:",
|
||||
],
|
||||
"Usage:",
|
||||
5,
|
||||
use_color,
|
||||
)?;
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Commands:",
|
||||
&["Options:", "Shared Options:", "Examples:"],
|
||||
"Commands:",
|
||||
8,
|
||||
use_color,
|
||||
)?;
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Subcommands:",
|
||||
&["Options:", "Shared Options:", "Examples:"],
|
||||
"Commands:",
|
||||
8,
|
||||
use_color,
|
||||
)?;
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Options:",
|
||||
&["Commands:", "Subcommands:", "Examples:"],
|
||||
"Common options:",
|
||||
8,
|
||||
use_color,
|
||||
)?;
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Shared Options:",
|
||||
&[
|
||||
"Commands:",
|
||||
"Subcommands:",
|
||||
"Find Options:",
|
||||
"Body Options:",
|
||||
"Examples:",
|
||||
],
|
||||
"Common options:",
|
||||
8,
|
||||
use_color,
|
||||
)?;
|
||||
write_named_section(
|
||||
&mut writer,
|
||||
help,
|
||||
"Examples:",
|
||||
&[],
|
||||
"Examples:",
|
||||
3,
|
||||
use_color,
|
||||
)?;
|
||||
|
||||
if use_color {
|
||||
writeln!(
|
||||
writer,
|
||||
"Type \x1b[1m{command} --help\x1b[0m for the full command reference."
|
||||
)
|
||||
} else {
|
||||
writeln!(
|
||||
writer,
|
||||
"Type '{command} --help' for the full command reference."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn first_help_line(help: &str) -> Option<&str> {
|
||||
help.lines().map(str::trim).find(|line| !line.is_empty())
|
||||
}
|
||||
|
||||
fn quick_help_command(help: &str) -> Option<&str> {
|
||||
help_section(
|
||||
help,
|
||||
"Usage:",
|
||||
&[
|
||||
"Commands:",
|
||||
"Subcommands:",
|
||||
"Options:",
|
||||
"Shared Options:",
|
||||
"Examples:",
|
||||
],
|
||||
)
|
||||
.and_then(|lines| lines.into_iter().find_map(first_usage_token))
|
||||
}
|
||||
|
||||
fn first_usage_token(line: &str) -> Option<&str> {
|
||||
line.split_whitespace()
|
||||
.next()
|
||||
.filter(|token| token.chars().any(char::is_alphanumeric))
|
||||
}
|
||||
|
||||
fn write_quick_heading(writer: &mut impl Write, heading: &str, use_color: bool) -> io::Result<()> {
|
||||
if use_color {
|
||||
writeln!(writer, "\x1b[1;36m{heading}\x1b[0m")
|
||||
} else {
|
||||
writeln!(writer, "{heading}")
|
||||
}
|
||||
}
|
||||
|
||||
fn write_named_section(
|
||||
writer: &mut impl Write,
|
||||
help: &str,
|
||||
source_heading: &str,
|
||||
stop_headings: &[&str],
|
||||
display_heading: &str,
|
||||
limit: usize,
|
||||
use_color: bool,
|
||||
) -> io::Result<()> {
|
||||
if let Some(lines) = help_section(help, source_heading, stop_headings) {
|
||||
write_quick_heading(writer, display_heading, use_color)?;
|
||||
write_limited_section(writer, lines, limit)?;
|
||||
writeln!(writer)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn help_section<'a>(help: &'a str, heading: &str, stop_headings: &[&str]) -> Option<Vec<&'a str>> {
|
||||
let mut lines = help.lines();
|
||||
for line in lines.by_ref() {
|
||||
if line.trim() == heading {
|
||||
let mut section = Vec::new();
|
||||
for candidate in lines {
|
||||
let trimmed = candidate.trim();
|
||||
if stop_headings.contains(&trimmed) || is_top_level_help_heading(candidate, trimmed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
section.push(candidate);
|
||||
}
|
||||
}
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_top_level_help_heading(raw: &str, trimmed: &str) -> bool {
|
||||
!trimmed.is_empty() && raw == trimmed && trimmed.ends_with(':')
|
||||
}
|
||||
|
||||
fn write_limited_section(
|
||||
writer: &mut impl Write,
|
||||
lines: Vec<&str>,
|
||||
limit: usize,
|
||||
) -> io::Result<()> {
|
||||
for line in lines.into_iter().take(limit) {
|
||||
writeln!(writer, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Maps a result count to the shared exit code contract.
|
||||
#[must_use]
|
||||
pub const fn map_result_count(count: usize) -> ExitCode {
|
||||
if count == 0 {
|
||||
ExitCode::NoResults
|
||||
} else {
|
||||
ExitCode::Success
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn cli_error_exit_codes_match_categories() {
|
||||
assert_eq!(CliError::usage("bad").exit_code(), ExitCode::UsageError);
|
||||
assert_eq!(CliError::runtime("bad").exit_code(), ExitCode::RuntimeError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_helpers_succeed() {
|
||||
assert!(print_json(&json!({"ok": true})).is_ok());
|
||||
assert!(print_text("ok").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdout_write_errors_ignore_broken_pipe_only() {
|
||||
assert!(
|
||||
stdout_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))).is_ok()
|
||||
);
|
||||
assert!(matches!(
|
||||
stdout_write_result(Err(io::Error::other("disk"))),
|
||||
Err(CliError::Runtime(message)) if message.contains("failed to write stdout: disk")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_args_render_mode_and_result_mapping_are_stable() {
|
||||
assert_eq!(CommonArgs::default().render_mode(), RenderMode::Text);
|
||||
assert_eq!(
|
||||
CommonArgs {
|
||||
json: true,
|
||||
format: None,
|
||||
quiet: false,
|
||||
color: ColorChoice::Auto,
|
||||
}
|
||||
.render_mode(),
|
||||
RenderMode::Json
|
||||
);
|
||||
assert_eq!(map_result_count(0), ExitCode::NoResults);
|
||||
assert_eq!(map_result_count(3), ExitCode::Success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_color_choice_accepts_known_values_and_rejects_unknowns() {
|
||||
assert_eq!(parse_color_choice("auto").expect("auto"), ColorChoice::Auto);
|
||||
assert_eq!(
|
||||
parse_color_choice("never").expect("never"),
|
||||
ColorChoice::Never
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_color_choice("always"),
|
||||
Err(CliError::Usage(message))
|
||||
if message.contains("invalid --color value 'always'")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_choice_and_toon_writer_cover_nested_values() {
|
||||
assert_eq!(parse_format_choice("text").expect("text"), RenderMode::Text);
|
||||
assert_eq!(parse_format_choice("json").expect("json"), RenderMode::Json);
|
||||
assert_eq!(parse_format_choice("toon").expect("toon"), RenderMode::Toon);
|
||||
assert!(parse_format_choice("yaml").is_err());
|
||||
|
||||
let mut output = Vec::new();
|
||||
write_toon_value(
|
||||
&mut output,
|
||||
&json!({
|
||||
"meta": {"ok": true, "count": 2},
|
||||
"items": ["a", {"nested": true}],
|
||||
"none": null
|
||||
}),
|
||||
0,
|
||||
)
|
||||
.expect("toon writer");
|
||||
let rendered = String::from_utf8(output).expect("utf8");
|
||||
assert!(rendered.contains("meta:\n"));
|
||||
assert!(rendered.contains(" ok: true\n"));
|
||||
assert!(rendered.contains(" count: 2\n"));
|
||||
assert!(rendered.contains("items:\n - a\n - {}\n"));
|
||||
assert!(rendered.contains("none: null\n"));
|
||||
|
||||
assert_eq!(toon_primitive(&json!("plain")), "plain");
|
||||
assert_eq!(toon_primitive(&json!({ "nested": true })), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_help_extracts_sections_and_limits_output() {
|
||||
let help = "\
|
||||
Example command.
|
||||
|
||||
Usage:
|
||||
example [OPTIONS] <PATH>
|
||||
|
||||
Commands:
|
||||
run
|
||||
inspect
|
||||
|
||||
Options:
|
||||
--json
|
||||
--toon
|
||||
--verbose
|
||||
|
||||
Examples:
|
||||
example README.md
|
||||
example --json config.json
|
||||
";
|
||||
assert_eq!(first_help_line(help), Some("Example command."));
|
||||
assert_eq!(quick_help_command(help), Some("example"));
|
||||
assert_eq!(
|
||||
help_section(help, "Commands:", &["Options:"]).expect("commands"),
|
||||
vec![" run", " inspect"]
|
||||
);
|
||||
assert!(is_top_level_help_heading("Options:", "Options:"));
|
||||
assert!(!is_top_level_help_heading(" --json", "--json"));
|
||||
|
||||
let mut output = Vec::new();
|
||||
write_quick_help(&mut output, help, false).expect("plain quick help");
|
||||
let rendered = String::from_utf8(output).expect("utf8");
|
||||
assert!(rendered.contains("example - Mercury Toolbox"));
|
||||
assert!(rendered.contains("Common options:"));
|
||||
assert!(rendered.contains("Type 'example --help'"));
|
||||
|
||||
let mut colored = Vec::new();
|
||||
write_quick_heading(&mut colored, "Title", true).expect("colored heading");
|
||||
assert!(
|
||||
String::from_utf8(colored)
|
||||
.expect("utf8")
|
||||
.contains("\x1b[1;36m")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user