use std::io::IsTerminal;
use owo_colors::{OwoColorize, Style};
#[derive(PartialEq)]
enum Section {
None,
Usage,
Commands,
Options,
Arguments,
}
pub fn color_wanted() -> bool {
std::io::stdout().is_terminal()
&& std::env::var_os("NO_COLOR").is_none()
&& !std::env::args().any(|arg| arg == "--no-color")
}
pub fn render(help: &str, color: bool) -> String {
let heading = Style::new().green().bold();
let usage = Style::new().bright_yellow().bold();
let command = Style::new().blue().bold();
let option = Style::new().magenta().bold();
let mut out = String::new();
let mut section = Section::None;
for line in help.lines() {
if let Some(rest) = line.strip_prefix("Usage:") {
section = Section::Usage;
out.push_str(&paint("USAGE:", heading, color));
out.push('\n');
let body = rest.trim();
if !body.is_empty() {
out.push_str(" ");
out.push_str(&paint(body, usage, color));
}
} else if line == "Commands:" {
section = Section::Commands;
out.push_str(&paint("COMMANDS:", heading, color));
} else if line == "Options:" {
section = Section::Options;
out.push_str(&paint("OPTIONS:", heading, color));
} else if line == "Arguments:" {
section = Section::Arguments;
out.push_str(&paint("ARGUMENTS:", heading, color));
} else if line.trim().is_empty() {
if section == Section::Usage {
section = Section::None;
}
out.push_str(line);
} else {
out.push_str(&paint_entry(line, §ion, usage, command, option, color));
}
out.push('\n');
}
out
}
fn paint_entry(
line: &str,
section: &Section,
usage: Style,
command: Style,
option: Style,
color: bool,
) -> String {
let indent_len = line.len() - line.trim_start().len();
let (indent, body) = line.split_at(indent_len);
match section {
Section::Usage => format!("{indent}{}", paint(body, usage, color)),
Section::Commands if indent_len == 2 => match body.split_once(' ') {
Some((name, rest)) => format!("{indent}{} {rest}", paint(name, command, color)),
None => format!("{indent}{}", paint(body, command, color)),
},
Section::Options | Section::Arguments
if indent_len <= 6 && body.starts_with(['-', '<', '[']) =>
{
match body.split_once(" ") {
Some((flags, rest)) => {
format!("{indent}{} {rest}", paint(flags, option, color))
}
None => format!("{indent}{}", paint(body, option, color)),
}
}
_ => line.to_string(),
}
}
fn paint(text: &str, style: Style, color: bool) -> String {
if color {
text.style(style).to_string()
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "The missing developer experience for SOPS
Usage: sopsy [OPTIONS] <COMMAND> [COMMAND-OPTIONS]
Commands:
init Bootstrap an encrypted repository
join Request membership [aliases:
request-access]
Arguments:
<FILE> The encrypted file to edit
[SOPS_ARGS]... Extra arguments forwarded to `sops`
Options:
-y, --non-interactive Disable all interactive prompts; fail instead of asking. Also
enabled automatically when stdout is not a TTY
--no-color Disable colored output
";
#[test]
fn headings_are_recased_even_without_color() {
let plain = render(SAMPLE, false);
assert!(plain.contains("USAGE:\n sopsy [OPTIONS] <COMMAND> [COMMAND-OPTIONS]"));
assert!(plain.contains("COMMANDS:"));
assert!(plain.contains("OPTIONS:"));
assert!(plain.contains("ARGUMENTS:"));
assert!(!plain.contains('\u{1b}'));
assert!(plain.contains(" init Bootstrap an encrypted repository"));
}
#[test]
fn usage_body_moves_to_its_own_line() {
let plain = render(SAMPLE, false);
assert!(
plain
.lines()
.any(|l| l == " sopsy [OPTIONS] <COMMAND> [COMMAND-OPTIONS]"),
"usage body should sit alone on a two-space-indented line:\n{plain}"
);
}
#[test]
fn colored_render_paints_each_section_kind() {
let colored = render(SAMPLE, true);
for painted in ["USAGE:", "COMMANDS:", "OPTIONS:", "ARGUMENTS:"] {
let line = colored
.lines()
.find(|l| l.contains(painted))
.unwrap_or_else(|| panic!("missing {painted}"));
assert!(line.contains('\u{1b}'), "{painted} should be styled");
}
for (needle, label) in [
("init", "command name"),
("-y, --non-interactive", "flag cluster"),
("--no-color", "long-only flag"),
("<FILE>", "argument placeholder"),
] {
let line = colored
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("missing {needle}"));
assert!(
line.contains('\u{1b}'),
"{label} `{needle}` should be styled"
);
}
for continuation in ["request-access]", "enabled automatically"] {
let line = colored
.lines()
.find(|l| l.contains(continuation))
.unwrap_or_else(|| panic!("missing {continuation}"));
assert!(
!line.contains('\u{1b}'),
"continuation `{continuation}` must stay plain: {line}"
);
}
}
#[test]
fn commands_are_blue_options_are_magenta() {
let colored = render(SAMPLE, true);
let init = colored.lines().find(|l| l.contains("init")).unwrap();
let no_color = colored.lines().find(|l| l.contains("--no-color")).unwrap();
assert!(init.contains("34m") || init.contains("34;"), "{init}");
assert!(
no_color.contains("35m") || no_color.contains("35;"),
"{no_color}"
);
}
#[test]
fn description_text_after_the_flag_gap_stays_plain() {
let colored = render(SAMPLE, true);
let line = colored
.lines()
.find(|l| l.contains("Disable colored output"))
.unwrap();
let reset = line.find("\u{1b}[0m").expect("styled flag should reset");
let desc = line.find("Disable colored output").unwrap();
assert!(
reset < desc,
"description should not inherit the flag style: {line}"
);
}
}