chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+20
View File
@@ -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
+614
View File
@@ -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")
);
}
}