#![cfg(feature = "cli")]
use std::ffi::OsString;
use std::io::{self, BufRead, Read, Write};
use std::process::ExitCode;
#[cfg(feature = "completions")]
use clap::CommandFactory;
use clap::Parser;
#[cfg(feature = "strict-compat")]
use rusty_figlet::clamp_input_latin1;
use rusty_figlet::{
Banner, CompatibilityMode, Figlet, FigletBuilder, FigletError, Font, JustifyFlag, JustifyFlags,
LayoutFlag, LayoutFlags,
};
const STDIN_CAP_BYTES: usize = 1024 * 1024;
const EXIT_USAGE: u8 = 2;
fn main() -> ExitCode {
let argv: Vec<OsString> = std::env::args_os().collect();
let argv0 = argv
.first()
.cloned()
.unwrap_or_else(|| "rusty-figlet".into());
let argv_tail: Vec<OsString> = argv.iter().skip(1).cloned().collect();
let mode = resolve_mode(&argv_tail, &argv0);
let result = match mode {
#[cfg(feature = "strict-compat")]
CompatibilityMode::Strict => run_strict(&argv_tail),
#[cfg(not(feature = "strict-compat"))]
CompatibilityMode::Strict => {
eprintln!(
"rusty-figlet: built without strict-compat leaf — falling back to default mode"
);
run_default(&argv_tail)
}
CompatibilityMode::Default => run_default(&argv_tail),
_ => run_default(&argv_tail),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(BinError::Strict(stderr_line)) => {
let mapped = stderr_line.replacen("figlet:", "rusty-figlet:", 1);
eprintln!("{mapped}");
ExitCode::from(EXIT_USAGE)
}
Err(BinError::Figlet(err)) => {
eprintln!("rusty-figlet: {err}");
ExitCode::from(EXIT_USAGE)
}
}
}
enum BinError {
#[allow(dead_code)]
Strict(String),
Figlet(FigletError),
}
impl From<FigletError> for BinError {
fn from(err: FigletError) -> Self {
Self::Figlet(err)
}
}
fn resolve_mode(argv_tail: &[OsString], argv0: &std::ffi::OsStr) -> CompatibilityMode {
use std::path::Path;
let mut last: Option<bool> = None;
for token in argv_tail {
if let Some(s) = token.to_str() {
match s {
"--strict" => last = Some(true),
"--no-strict" => last = Some(false),
_ => {}
}
}
}
if let Some(b) = last {
return if b {
CompatibilityMode::Strict
} else {
CompatibilityMode::Default
};
}
if let Ok(value) = std::env::var("RUSTY_FIGLET_STRICT") {
let v = value.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes") {
return CompatibilityMode::Strict;
}
}
let stem = Path::new(argv0).file_stem().and_then(|s| s.to_str());
if matches!(stem, Some("figlet") | Some("figlet-alias")) {
return CompatibilityMode::Strict;
}
CompatibilityMode::Default
}
fn run_default(argv_tail: &[OsString]) -> Result<(), BinError> {
let cli = BinCli::parse();
#[cfg(feature = "completions")]
if let Some(BinSubcommand::Completions { shell }) = cli.subcommand {
let mut cmd = BinCli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut io::stdout());
return Ok(());
}
#[cfg(feature = "color")]
let no_color_env = std::env::var_os("NO_COLOR")
.map(|v| !v.is_empty())
.unwrap_or(false);
#[cfg(feature = "color")]
let color_choice = match cli.color {
BinColorChoice::Auto => rusty_figlet::color::ColorChoice::Auto,
BinColorChoice::Always => rusty_figlet::color::ColorChoice::Always,
BinColorChoice::Never => rusty_figlet::color::ColorChoice::Never,
};
#[cfg(feature = "color")]
let stdout_is_tty = is_stdout_tty();
#[cfg(feature = "color")]
let use_color = rusty_figlet::color::should_color(color_choice, no_color_env, stdout_is_tty);
#[cfg(all(feature = "color", feature = "rainbow"))]
let rainbow_active = use_color && cli.rainbow;
#[cfg(all(feature = "color", not(feature = "rainbow")))]
let rainbow_active: bool = false;
if cli._control_file.is_some() || cli._no_controlfile {
warn_control_file_ignored();
}
let occurrences = collect_flag_occurrences(argv_tail);
let font = map_font(cli.font.as_deref())?;
let mut builder = FigletBuilder::new().font(font);
if !cli.font_dirs.is_empty() {
builder = builder.font_dirs(cli.font_dirs.clone());
}
#[cfg(feature = "terminal-width")]
let width = {
let columns_env = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<u32>().ok());
let is_tty = is_stdout_tty_default();
rusty_figlet::resolve_width_for(
cli.width,
cli._use_terminal_width,
columns_env,
is_tty,
CompatibilityMode::Default,
)
};
#[cfg(not(feature = "terminal-width"))]
let width = cli.width.unwrap_or(80);
builder = builder.width(width);
builder = builder.layout(occurrences.layout_flags.clone());
let justify = rusty_figlet::resolve_justify_for(&occurrences.justify_flags);
builder = builder.justify(justify);
let figlet = builder.build()?;
#[cfg(feature = "color")]
if use_color {
let mut stream = termcolor::StandardStream::stdout(termcolor::ColorChoice::Always);
if !cli.message.is_empty() {
let text = cli.message.join(" ");
let banner = figlet.render(&text)?;
write_banner_with_color(&banner, rainbow_active, &mut stream)?;
return Ok(());
}
let stdin = io::stdin();
let mut handle = stdin.lock();
let text = read_stdin_capped(&mut handle)?;
if text.is_empty() {
return Ok(());
}
let paragraph = occurrences.paragraph;
if paragraph {
render_paragraph_color(&figlet, &text, rainbow_active, &mut stream)?;
} else {
render_normal_color(&figlet, &text, rainbow_active, &mut stream)?;
}
return Ok(());
}
let stdout = io::stdout();
let mut out = stdout.lock();
if !cli.message.is_empty() {
let text = cli.message.join(" ");
let banner = figlet.render(&text)?;
write_banner_lines(&banner, &mut out)?;
return Ok(());
}
let stdin = io::stdin();
let mut handle = stdin.lock();
let text = read_stdin_capped(&mut handle)?;
if text.is_empty() {
return Ok(());
}
let paragraph = occurrences.paragraph;
if paragraph {
render_paragraph_mode(&figlet, &text, &mut out)?;
} else {
render_normal_mode(&figlet, &text, &mut out)?;
}
Ok(())
}
#[cfg(feature = "color")]
fn write_banner_with_color<W: termcolor::WriteColor>(
banner: &Banner,
rainbow_active: bool,
out: &mut W,
) -> Result<(), BinError> {
let cfg = if rainbow_active {
#[cfg(feature = "rainbow")]
{
let max_width = banner.lines().map(|l| l.chars().count()).max().unwrap_or(0) as u32;
Some(rusty_figlet::output::ColorConfig {
rainbow_palette: Some(rusty_figlet::color::rainbow_palette(max_width)),
})
}
#[cfg(not(feature = "rainbow"))]
{
let _ = banner;
None
}
} else {
None
};
rusty_figlet::output::write_banner(banner, cfg.as_ref(), out).map_err(FigletError::from)?;
Ok(())
}
#[cfg(feature = "color")]
fn render_normal_color<W: termcolor::WriteColor>(
figlet: &Figlet,
text: &str,
rainbow_active: bool,
out: &mut W,
) -> Result<(), BinError> {
let mut first_banner = true;
for line in text.split('\n') {
if line.is_empty() {
continue;
}
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
let banner = figlet.render(line)?;
write_banner_with_color(&banner, rainbow_active, out)?;
first_banner = false;
}
Ok(())
}
#[cfg(feature = "color")]
fn render_paragraph_color<W: termcolor::WriteColor>(
figlet: &Figlet,
text: &str,
rainbow_active: bool,
out: &mut W,
) -> Result<(), BinError> {
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.split('\n') {
if line.is_empty() {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
paragraphs.push(current.join(" "));
}
let mut first_banner = true;
for para in ¶graphs {
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
let banner = figlet.render(para)?;
write_banner_with_color(&banner, rainbow_active, out)?;
first_banner = false;
}
Ok(())
}
fn render_normal_mode<W: Write>(figlet: &Figlet, text: &str, out: &mut W) -> Result<(), BinError> {
let mut first_banner = true;
for line in text.split('\n') {
if line.is_empty() {
continue;
}
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
let banner = figlet.render(line)?;
write_banner_lines(&banner, out)?;
first_banner = false;
}
Ok(())
}
fn render_paragraph_mode<W: Write>(
figlet: &Figlet,
text: &str,
out: &mut W,
) -> Result<(), BinError> {
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.split('\n') {
if line.is_empty() {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
paragraphs.push(current.join(" "));
}
let mut first_banner = true;
for para in ¶graphs {
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
let banner = figlet.render(para)?;
write_banner_lines(&banner, out)?;
first_banner = false;
}
Ok(())
}
#[cfg(any(feature = "color", feature = "terminal-width"))]
fn is_stdout_tty() -> bool {
use std::io::IsTerminal;
io::stdout().is_terminal()
}
#[cfg(feature = "terminal-width")]
fn is_stdout_tty_default() -> bool {
is_stdout_tty()
}
#[derive(Debug, Default)]
struct Occurrences {
layout_flags: LayoutFlags,
justify_flags: JustifyFlags,
paragraph: bool,
}
fn collect_flag_occurrences(argv_tail: &[OsString]) -> Occurrences {
let mut occ = Occurrences::default();
let mut i = 0usize;
while i < argv_tail.len() {
let Some(tok) = argv_tail[i].to_str() else {
i += 1;
continue;
};
if tok == "--" {
break;
}
if let Some(long) = tok.strip_prefix("--") {
let (name, value) = match long.find('=') {
Some(eq) => (&long[..eq], Some(&long[eq + 1..])),
None => (long, None),
};
match name {
"center" => occ.justify_flags.flags.push(JustifyFlag::Center),
"left" => occ.justify_flags.flags.push(JustifyFlag::Left),
"right" => occ.justify_flags.flags.push(JustifyFlag::Right),
"font-default-justify" => occ.justify_flags.flags.push(JustifyFlag::FontDefault),
"kerning" => occ.layout_flags.flags.push(LayoutFlag::Kerning),
"full-width" => occ.layout_flags.flags.push(LayoutFlag::FullWidth),
"force-smush" => occ.layout_flags.flags.push(LayoutFlag::ForceSmush),
"smush" => occ.layout_flags.flags.push(LayoutFlag::FontDefaultSmush),
"overlap" => occ.layout_flags.flags.push(LayoutFlag::OverlapOnly),
"layout-mode" => {
let v = value.map(str::to_owned).or_else(|| {
argv_tail
.get(i + 1)
.and_then(|os| os.to_str().map(str::to_owned))
});
if value.is_none() {
i += 1;
}
if let Some(s) = v {
if let Ok(n) = s.parse::<i32>() {
occ.layout_flags.flags.push(LayoutFlag::Explicit(n));
}
}
}
"paragraph" => occ.paragraph = true,
"normal" => occ.paragraph = false,
"font" | "fontdir" | "width" | "control-file" | "color" => {
if value.is_none() {
i += 1; }
}
_ => {}
}
i += 1;
continue;
}
if let Some(short_body) = tok.strip_prefix('-').filter(|s| !s.is_empty()) {
let chars: Vec<char> = short_body.chars().collect();
let mut idx = 0usize;
while idx < chars.len() {
let ch = chars[idx];
match ch {
'c' => occ.justify_flags.flags.push(JustifyFlag::Center),
'l' => occ.justify_flags.flags.push(JustifyFlag::Left),
'r' => occ.justify_flags.flags.push(JustifyFlag::Right),
'x' => occ.justify_flags.flags.push(JustifyFlag::FontDefault),
'k' => occ.layout_flags.flags.push(LayoutFlag::Kerning),
'W' => occ.layout_flags.flags.push(LayoutFlag::FullWidth),
'S' => occ.layout_flags.flags.push(LayoutFlag::ForceSmush),
's' => occ.layout_flags.flags.push(LayoutFlag::FontDefaultSmush),
'o' => occ.layout_flags.flags.push(LayoutFlag::OverlapOnly),
'p' => occ.paragraph = true,
'n' => occ.paragraph = false,
'm' => {
let value = if idx + 1 < chars.len() {
let v: String = chars[idx + 1..].iter().collect();
idx = chars.len();
Some(v)
} else {
i += 1;
argv_tail
.get(i)
.and_then(|os| os.to_str().map(str::to_owned))
};
if let Some(s) = value {
if let Ok(n) = s.parse::<i32>() {
occ.layout_flags.flags.push(LayoutFlag::Explicit(n));
}
}
}
'f' | 'd' | 'w' | 'C' => {
if idx + 1 >= chars.len() {
i += 1;
}
idx = chars.len();
}
_ => {}
}
idx += 1;
}
i += 1;
continue;
}
i += 1;
}
occ
}
fn write_banner_lines<W: Write>(banner: &Banner, out: &mut W) -> Result<(), FigletError> {
for line in banner.lines() {
writeln!(out, "{line}").map_err(FigletError::from)?;
}
Ok(())
}
#[cfg(feature = "strict-compat")]
fn run_strict(argv_tail: &[OsString]) -> Result<(), BinError> {
use rusty_figlet::strict;
let args = match strict::parse_argv(argv_tail) {
Ok(a) => a,
Err(err) => return Err(BinError::Strict(err.message().to_owned())),
};
if let Some(first) = args.message.first() {
if first == "completions" {
let msg = strict::format_unknown_flag("completions");
return Err(BinError::Strict(msg));
}
}
let font = map_font(args.font.as_deref())?;
let mut builder = FigletBuilder::new().font(font);
if !args.font_dirs.is_empty() {
builder = builder.font_dirs(args.font_dirs.clone());
}
#[cfg(feature = "terminal-width")]
let width = {
let columns_env = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<u32>().ok());
let is_tty = is_stdout_tty();
rusty_figlet::resolve_width_for(
args.width,
args.use_terminal_width,
columns_env,
is_tty,
CompatibilityMode::Strict,
)
};
#[cfg(not(feature = "terminal-width"))]
let width = args.width.unwrap_or(80);
builder = builder.width(width);
let mut layout_flags = LayoutFlags::default();
if let Some(kind) = args.layout {
layout_flags.flags.push(match kind {
rusty_figlet::strict::LayoutKind::Kerning => LayoutFlag::Kerning,
rusty_figlet::strict::LayoutKind::FullWidth => LayoutFlag::FullWidth,
rusty_figlet::strict::LayoutKind::ForceSmush => LayoutFlag::ForceSmush,
rusty_figlet::strict::LayoutKind::DefaultSmush => LayoutFlag::FontDefaultSmush,
rusty_figlet::strict::LayoutKind::OverlapOnly => LayoutFlag::OverlapOnly,
rusty_figlet::strict::LayoutKind::Explicit(n) => LayoutFlag::Explicit(n),
});
}
builder = builder.layout(layout_flags);
let mut justify_flags = JustifyFlags::default();
if let Some(kind) = args.justify {
justify_flags.flags.push(match kind {
rusty_figlet::strict::JustifyKind::Center => JustifyFlag::Center,
rusty_figlet::strict::JustifyKind::Left => JustifyFlag::Left,
rusty_figlet::strict::JustifyKind::Right => JustifyFlag::Right,
rusty_figlet::strict::JustifyKind::FontDefault => JustifyFlag::FontDefault,
});
}
let justify = rusty_figlet::resolve_justify_for(&justify_flags);
builder = builder.justify(justify);
let figlet = builder.build()?;
let stdout = io::stdout();
let mut out = stdout.lock();
if !args.message.is_empty() {
let text = args.message.join(" ");
render_latin1(&figlet, &text, &mut out)?;
return Ok(());
}
let stdin = io::stdin();
let mut handle = stdin.lock();
let text = read_stdin_capped(&mut handle)?;
if text.is_empty() {
return Ok(());
}
let paragraph = args.paragraph.unwrap_or(false);
if paragraph {
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.split('\n') {
if line.is_empty() {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
paragraphs.push(current.join(" "));
}
let mut first_banner = true;
for para in ¶graphs {
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
render_latin1(&figlet, para, &mut out)?;
first_banner = false;
}
} else {
let mut first_banner = true;
for line in text.split('\n') {
if line.is_empty() {
continue;
}
if !first_banner {
writeln!(out).map_err(FigletError::from)?;
}
render_latin1(&figlet, line, &mut out)?;
first_banner = false;
}
}
Ok(())
}
#[cfg(feature = "strict-compat")]
fn render_latin1<W: Write>(figlet: &Figlet, text: &str, out: &mut W) -> Result<(), FigletError> {
let clamped = clamp_input_latin1(text);
let s: String = clamped.into_iter().map(char::from).collect();
let banner = figlet.render(&s)?;
write_banner_lines(&banner, out)
}
fn map_font(name: Option<&str>) -> Result<Font, FigletError> {
let Some(raw) = name else {
return Ok(Font::Standard);
};
let bare = raw.strip_suffix(".flf").unwrap_or(raw);
Ok(match bare {
"standard" => Font::Standard,
"slant" => Font::Slant,
"small" => Font::Small,
"big" => Font::Big,
"mini" => Font::Mini,
"banner" => Font::Banner,
"block" => Font::Block,
"bubble" => Font::Bubble,
"digital" => Font::Digital,
"lean" => Font::Lean,
"script" => Font::Script,
"shadow" => Font::Shadow,
_ => Font::External(std::path::PathBuf::from(raw)),
})
}
fn read_stdin_capped<R: BufRead>(handle: &mut R) -> Result<String, FigletError> {
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
let mut limited = handle.take(STDIN_CAP_BYTES as u64 + 1);
limited.read_to_end(&mut buf).map_err(FigletError::from)?;
let truncated = buf.len() > STDIN_CAP_BYTES;
if truncated {
buf.truncate(STDIN_CAP_BYTES);
warn_stdin_cap();
}
let text = String::from_utf8_lossy(&buf).into_owned();
Ok(text.trim_end_matches('\n').to_owned())
}
use std::sync::OnceLock;
static STDIN_CAP_WARNED: OnceLock<()> = OnceLock::new();
static CONTROL_FILE_WARNED: OnceLock<()> = OnceLock::new();
fn warn_stdin_cap() {
if STDIN_CAP_WARNED.set(()).is_ok() {
eprintln!("rusty-figlet: stdin input capped at 1 MiB; remaining input discarded");
}
}
fn warn_control_file_ignored() {
if CONTROL_FILE_WARNED.set(()).is_ok() {
eprintln!("rusty-figlet: control files not yet implemented; ignoring -C/-N");
}
}
#[cfg(feature = "color")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum)]
#[value(rename_all = "lower")]
enum BinColorChoice {
Auto,
Always,
Never,
}
#[derive(Debug, clap::Parser)]
#[command(
name = "rusty-figlet",
version,
about = "Render ASCII-art banners from text"
)]
struct BinCli {
#[arg(short = 'f', long = "font", value_name = "FONT")]
font: Option<String>,
#[arg(short = 'd', long = "fontdir", value_name = "DIR")]
font_dirs: Vec<std::path::PathBuf>,
#[arg(short = 'w', long = "width", value_name = "INT")]
width: Option<u32>,
#[arg(short = 't', long = "terminal-width")]
_use_terminal_width: bool,
#[arg(short = 'c', long = "center")]
_center: bool,
#[arg(short = 'l', long = "left")]
_left: bool,
#[arg(short = 'r', long = "right")]
_right: bool,
#[arg(short = 'x', long = "font-default-justify")]
_justify_default: bool,
#[arg(short = 'k', long = "kerning")]
_kerning: bool,
#[arg(short = 'W', long = "full-width")]
_full_width: bool,
#[arg(short = 'S', long = "force-smush")]
_force_smush: bool,
#[arg(short = 's', long = "smush")]
_default_smush: bool,
#[arg(short = 'o', long = "overlap")]
_overlap: bool,
#[arg(
short = 'm',
long = "layout-mode",
value_name = "INT",
allow_hyphen_values = true
)]
_explicit_layout: Option<i32>,
#[arg(short = 'p', long = "paragraph")]
_paragraph: bool,
#[arg(short = 'n', long = "normal")]
_normal: bool,
#[arg(short = 'C', long = "control-file", value_name = "FILE")]
_control_file: Option<std::path::PathBuf>,
#[arg(short = 'N', long = "no-controlfile")]
_no_controlfile: bool,
#[cfg(feature = "color")]
#[arg(long = "color", value_name = "WHEN", value_enum, default_value_t = BinColorChoice::Auto)]
color: BinColorChoice,
#[cfg(feature = "rainbow")]
#[arg(long = "rainbow")]
rainbow: bool,
#[arg(long = "strict")]
_strict: bool,
#[arg(long = "no-strict")]
_no_strict: bool,
#[arg(value_name = "MESSAGE", trailing_var_arg = true)]
message: Vec<String>,
#[cfg(feature = "completions")]
#[command(subcommand)]
subcommand: Option<BinSubcommand>,
}
#[cfg(feature = "completions")]
#[derive(Debug, clap::Subcommand)]
enum BinSubcommand {
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}