#![cfg(feature = "cli")]
use std::fs;
use std::io::Write;
use std::time::Instant;
#[path = "common/mod.rs"]
mod common;
#[test]
fn default_font_renders_hello() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd().arg("Hello").assert().success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty(), "stdout must be non-empty");
let line_count = stdout.lines().count();
assert!(
line_count >= 1,
"expected >= 1 rendered line, got {line_count}"
);
assert!(
stdout.contains('H') && stdout.contains('e'),
"rendered banner should mention input chars; got:\n{stdout}"
);
}
#[test]
fn stdin_pipe_renders_each_line_as_banner() {
let (_tmp, _) = common::sandbox();
let start = Instant::now();
let assert = common::rusty_figlet_cmd()
.write_stdin("test\n")
.assert()
.success();
let elapsed = start.elapsed();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty(), "stdin pipe must produce a banner");
assert!(
elapsed.as_secs() < 5,
"render took {elapsed:?}; integration soft cap is 5s"
);
}
#[test]
fn positional_args_concatenated_with_space() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.args(["Hello", "World"])
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty(), "joined positional args must render");
let blank_separators = stdout.split('\n').filter(|s| s.is_empty()).count();
assert!(
blank_separators <= 1,
"expected single banner (no inter-banner blank separator); got stdout:\n{stdout}"
);
}
#[test]
fn positional_arg_ignores_stdin() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.write_stdin("stdin_text\n")
.arg("Banner")
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains('B'),
"expected positional 'Banner' to render; got:\n{stdout}"
);
assert!(
!stdout.contains("stdin_text"),
"stdin must be ignored when positional present; got:\n{stdout}"
);
}
#[test]
fn empty_input_exits_zero_no_output() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.write_stdin("")
.assert()
.success();
let out = assert.get_output();
assert!(
out.stdout.is_empty(),
"empty input must produce no stdout; got {} bytes: {:?}",
out.stdout.len(),
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn stdin_cap_one_time_warning_per_process() {
let (_tmp, _) = common::sandbox();
let mut payload = Vec::with_capacity(2 * 1024 * 1024);
while payload.len() < 2 * 1024 * 1024 {
payload
.write_all(b"line\n")
.expect("synthetic payload write");
}
let assert = common::rusty_figlet_cmd()
.write_stdin(payload)
.assert()
.success();
let out = assert.get_output();
let stderr = String::from_utf8_lossy(&out.stderr);
let warning_count = stderr.matches("stdin input capped at 1 MiB").count();
assert_eq!(
warning_count, 1,
"expected exactly one cap warning per process; got {warning_count}; stderr:\n{stderr}"
);
}
#[test]
fn stdin_lines_separated_by_blank_banner_gap() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.write_stdin("line one\nline two\n")
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.split('\n').collect();
let blank_lines = lines.iter().filter(|s| s.is_empty()).count();
assert!(
blank_lines >= 1,
"expected at least one blank-line separator between banners; got stdout:\n{stdout}"
);
}
#[test]
fn utf8_missing_glyph_one_time_warning() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd().arg("中中").assert().success();
let out = assert.get_output();
let stderr = String::from_utf8_lossy(&out.stderr);
let warning_count = stderr.matches("codepoint U+").count();
assert!(
warning_count <= 1,
"missing-codepoint warning must fire at most once per process; got {warning_count} on stderr:\n{stderr}"
);
}
#[test]
fn all_twelve_bundled_fonts_resolve_via_dash_f() {
let names = [
"standard", "slant", "small", "big", "mini", "banner", "block", "bubble", "digital",
"lean", "script", "shadow",
];
for name in names {
let assert = common::rusty_figlet_cmd()
.args(["-f", name, "X"])
.assert()
.success();
let out = assert.get_output();
assert!(
!out.stdout.is_empty(),
"bundled font {name} must render non-empty banner; stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn external_flf_loads_from_disk_via_dash_f_path() {
let (_tmp, root) = common::sandbox();
let src =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/fonts/standard.flf");
let dst = root.join("standard.flf");
fs::copy(&src, &dst).expect("copy bundled standard.flf into sandbox");
let bundled = common::rusty_figlet_cmd()
.args(["-f", "standard", "X"])
.assert()
.success();
let external = common::rusty_figlet_cmd()
.args(["-f", dst.to_str().expect("utf8 path"), "X"])
.assert()
.success();
assert_eq!(
bundled.get_output().stdout,
external.get_output().stdout,
"external `.flf` load must render byte-identical to bundled load"
);
}
#[test]
fn exact_path_beats_dash_d_lookup() {
let (_tmp, root) = common::sandbox();
let dir_root = root.join("dirs");
fs::create_dir_all(&dir_root).expect("create dirs subdir");
fn flf_with_glyph_char(c: char) -> Vec<u8> {
let mut out = String::new();
out.push_str("flf2a$ 1 1 8 0 2 0 0 7\n");
out.push_str("comment line 1\n");
out.push_str("comment line 2\n");
for cp in 32..=126u32 {
let ch = char::from_u32(cp).unwrap();
let render = if ch.is_ascii_alphanumeric() { c } else { ch };
out.push_str(&format!("{render}$$$$$$$@@\n"));
}
for cp in [196u32, 214, 220, 228, 246, 252, 223] {
out.push_str(&format!("{cp:X} U+{cp:04X}\n"));
out.push_str(&format!("{c}$$$$$$$@@\n"));
}
out.into_bytes()
}
let exact_path = root.join("exact.flf");
fs::write(&exact_path, flf_with_glyph_char('E')).expect("write exact.flf");
let dirs_path = dir_root.join("exact.flf");
fs::write(&dirs_path, flf_with_glyph_char('D')).expect("write dirs/exact.flf");
let assert = common::rusty_figlet_cmd()
.args([
"-f",
exact_path.to_str().expect("utf8 path"),
"-d",
dir_root.to_str().expect("utf8 path"),
"A",
])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(
stdout.contains('E'),
"exact path must win over -d dir; expected 'E' marker, got:\n{stdout}"
);
assert!(
!stdout.contains('D'),
"dirs marker 'D' must NOT appear when exact path resolves; got:\n{stdout}"
);
}
#[test]
fn font_dir_flag_resolves_external_flf() {
let (_tmp, root) = common::sandbox();
let fonts_dir = root.join("fonts");
fs::create_dir_all(&fonts_dir).expect("create fonts dir");
let src =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/fonts/standard.flf");
let dst = fonts_dir.join("mycustom.flf");
fs::copy(&src, &dst).expect("copy standard.flf as mycustom.flf");
let assert = common::rusty_figlet_cmd()
.args([
"-d",
fonts_dir.to_str().expect("utf8 path"),
"-f",
"mycustom",
"X",
])
.assert()
.success();
assert!(
!assert.get_output().stdout.is_empty(),
"-d dir lookup must resolve `mycustom` from <sandbox>/fonts/"
);
}
#[test]
fn dash_f_with_or_without_flf_suffix() {
let bare = common::rusty_figlet_cmd()
.args(["-f", "slant", "X"])
.assert()
.success();
let suffixed = common::rusty_figlet_cmd()
.args(["-f", "slant.flf", "X"])
.assert()
.success();
assert_eq!(
bare.get_output().stdout,
suffixed.get_output().stdout,
"`-f slant` and `-f slant.flf` must render byte-identical"
);
}
#[test]
fn font_not_found_emits_clear_error_listing_searched_paths() {
let assert = common::rusty_figlet_cmd()
.args(["-f", "nonexistent_font_xyz.flf", "X"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains("nonexistent_font_xyz"),
"stderr must name the requested font; got:\n{stderr}"
);
assert!(
stderr.contains("font not found") || stderr.contains("FontNotFound"),
"stderr must convey 'font not found'; got:\n{stderr}"
);
}
#[test]
fn width_60_center_lines_le_60_visually_centered() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.args(["-w", "60", "-c", "X"])
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.is_empty(), "expected non-empty banner");
for (idx, line) in stdout.lines().enumerate() {
let col_count = line.chars().count();
assert!(
col_count <= 60,
"line {idx} exceeds 60 cols ({col_count}): {line:?}"
);
if col_count == 0 {
continue;
}
let leading = line.chars().take_while(|c| *c == ' ').count();
assert!(
leading >= 1,
"line {idx} must have leading whitespace for center justify; line: {line:?}"
);
assert!(
col_count <= 60,
"centered line {idx} must fit in 60 cols; got {col_count}: {line:?}"
);
}
}
#[test]
fn layout_class_flags_last_wins() {
let (_tmp, _) = common::sandbox();
let combos: &[&[&str]] = &[
&["-k", "-W", "-S", "X"], &["-W", "-k", "X"], &["-S", "-W", "X"], &["-m", "24", "-S", "X"], &["-S", "-m", "24", "X"], &["-W", "-S", "-k", "X"], ];
for argv in combos {
let assert = common::rusty_figlet_cmd().args(*argv).assert().success();
let out = assert.get_output();
assert!(
!out.stdout.is_empty(),
"layout combo {argv:?} must render; stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn justify_flags_last_wins() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.args(["-w", "80", "-c", "-l", "-r", "X"])
.assert()
.success();
let out = assert.get_output();
let stdout = String::from_utf8_lossy(&out.stdout);
let first = stdout.lines().next().unwrap_or_default();
assert!(
!first.is_empty(),
"first banner row must be non-empty; got: {stdout:?}"
);
let leading = first.chars().take_while(|c| *c == ' ').count();
assert!(
leading > 0,
"expected leading whitespace for right-justify (-r last); got first row: {first:?}"
);
let assert2 = common::rusty_figlet_cmd()
.args(["-w", "80", "-r", "-c", "X"])
.assert()
.success();
let out2 = assert2.get_output();
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let first2 = stdout2.lines().next().unwrap_or_default();
let leading2 = first2.chars().take_while(|c| *c == ' ').count();
assert!(
leading2 < 60,
"expected center-justify leading ≈ 39 (not 79); got {leading2} in: {first2:?}"
);
}
#[test]
fn over_width_word_warns_once_per_process() {
let (_tmp, _) = common::sandbox();
let assert = common::rusty_figlet_cmd()
.args(["-w", "5", "supercalifragilistic"])
.assert()
.success();
let out = assert.get_output();
let stderr = String::from_utf8_lossy(&out.stderr);
let warn_count = stderr.matches("too wide for width").count();
assert_eq!(
warn_count, 1,
"expected EXACTLY one over-width warning; got {warn_count}; stderr:\n{stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.is_empty(),
"expected the word to be rendered at full glyph width"
);
}
#[test]
fn paragraph_mode_concatenates_consecutive_lines() {
let (_tmp, _) = common::sandbox();
let assert_p = common::rusty_figlet_cmd()
.args(["-p"])
.write_stdin("a\nb\n\nc\n")
.assert()
.success();
let stdout_p = String::from_utf8_lossy(&assert_p.get_output().stdout);
let blank_lines_p = stdout_p.split('\n').filter(|s| s.is_empty()).count();
assert!(
(1..=2).contains(&blank_lines_p),
"paragraph mode: expected 1 inter-banner blank (got {blank_lines_p}); stdout:\n{stdout_p}"
);
let assert_n = common::rusty_figlet_cmd()
.args(["-n"])
.write_stdin("a\nb\n\nc\n")
.assert()
.success();
let stdout_n = String::from_utf8_lossy(&assert_n.get_output().stdout);
let blank_lines_n = stdout_n.split('\n').filter(|s| s.is_empty()).count();
assert!(
blank_lines_n >= 2,
"normal mode: expected ≥2 blank separators (got {blank_lines_n}); stdout:\n{stdout_n}"
);
}
#[test]
fn dash_m_explicit_layout_bitfield() {
let (_tmp, _) = common::sandbox();
for arg in ["0", "24", "63"] {
let assert = common::rusty_figlet_cmd()
.args(["-m", arg, "X"])
.assert()
.success();
let out = assert.get_output();
assert!(
!out.stdout.is_empty(),
"-m {arg} must render non-empty banner; stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
const ANSI_24BIT_FG_PREFIX: &[u8] = b"\x1b[38;2;";
#[test]
fn rainbow_emits_24bit_ansi_when_color_always() {
let _guard = common::env_guard("NO_COLOR", None);
let assert = common::rusty_figlet_cmd()
.args(["--rainbow", "--color=always", "X"])
.assert()
.success();
let out = assert.get_output();
let stdout = &out.stdout;
assert!(
stdout
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"expected 24-bit ANSI fg escape `\\x1b[38;2;…m` in stdout for --color=always --rainbow; got {} bytes",
stdout.len()
);
let plain = common::rusty_figlet_cmd()
.arg("X")
.assert()
.success()
.get_output()
.stdout
.clone();
let never = common::rusty_figlet_cmd()
.args(["--rainbow", "--color=never", "X"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(
!never
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"--color=never must suppress 24-bit ANSI escapes"
);
assert_eq!(
plain, never,
"--color=never bytes must equal plain non-color rendering"
);
}
#[test]
fn no_color_env_suppresses_regardless_of_flag() {
let _guard = common::env_guard("NO_COLOR", Some("1"));
let assert = common::rusty_figlet_cmd()
.args(["--rainbow", "--color=always", "X"])
.assert()
.success();
let out = assert.get_output();
let stdout = &out.stdout;
assert!(
!stdout
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"NO_COLOR=1 must suppress ANSI escapes even under --color=always; stdout had escapes"
);
let plain = common::rusty_figlet_cmd()
.args(["--color=never", "X"])
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
stdout.as_slice(),
plain.as_slice(),
"NO_COLOR=1 output bytes must match --color=never bytes"
);
}
#[test]
fn color_auto_no_escapes_on_non_tty() {
let _guard = common::env_guard("NO_COLOR", None);
let assert = common::rusty_figlet_cmd()
.args(["--color=auto", "--rainbow", "X"])
.assert()
.success();
let stdout = assert.get_output().stdout.clone();
assert!(
!stdout
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"--color=auto on piped (non-TTY) stdout must suppress ANSI escapes"
);
}
#[test]
fn color_always_overrides_tty_detection() {
let _guard = common::env_guard("NO_COLOR", None);
let always = common::rusty_figlet_cmd()
.args(["--color=always", "--rainbow", "X"])
.assert()
.success();
let stdout_always = always.get_output().stdout.clone();
assert!(
stdout_always
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"--color=always must emit ANSI escapes regardless of TTY status"
);
let never = common::rusty_figlet_cmd()
.args(["--color=never", "--rainbow", "X"])
.assert()
.success();
let stdout_never = never.get_output().stdout.clone();
assert!(
!stdout_never
.windows(ANSI_24BIT_FG_PREFIX.len())
.any(|w| w == ANSI_24BIT_FG_PREFIX),
"--color=never must suppress ANSI escapes"
);
}
#[test]
fn rainbow_gradient_spans_banner_width_not_w_budget() {
let _guard = common::env_guard("NO_COLOR", None);
let assert = common::rusty_figlet_cmd()
.args(["-w", "200", "--rainbow", "--color=always", "X"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone())
.expect("rainbow output should still be UTF-8");
let first_line = stdout.lines().find(|l| !l.is_empty()).unwrap_or("");
let rgb_triples = extract_rgb_triples(first_line);
assert!(
rgb_triples.len() >= 2,
"rainbow gradient on first line must emit ≥ 2 distinct column colors; got {} triples in: {first_line:?}",
rgb_triples.len()
);
let first_rgb = rgb_triples.first().copied().unwrap();
let last_rgb = rgb_triples.last().copied().unwrap();
assert_ne!(
first_rgb, last_rgb,
"first and last column colors must differ to prove per-column hue cycling"
);
let mut g_set = std::collections::BTreeSet::new();
let mut b_set = std::collections::BTreeSet::new();
for (_, g, b) in &rgb_triples {
g_set.insert(*g);
b_set.insert(*b);
}
assert!(
g_set.len() > 1 || b_set.len() > 1,
"G or B channel must vary across the line — gradient should span banner width, not be clamped to a tiny `-w 200` slice. g_set={g_set:?} b_set={b_set:?}"
);
}
fn extract_rgb_triples(s: &str) -> Vec<(u8, u8, u8)> {
let mut out = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i + 7 < bytes.len() {
if &bytes[i..i + 7] == b"\x1b[38;2;" {
let rest = &s[i + 7..];
if let Some(m) = rest.find('m') {
let payload = &rest[..m];
let parts: Vec<&str> = payload.split(';').collect();
if parts.len() == 3 {
if let (Ok(r), Ok(g), Ok(b)) = (
parts[0].parse::<u8>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
) {
out.push((r, g, b));
}
}
i += 7 + m + 1;
continue;
}
}
i += 1;
}
out
}
#[test]
fn default_mode_accepts_color_and_rainbow_flags() {
let _guard = common::env_guard("NO_COLOR", None);
for argv in [
["--color=auto", "X"].as_slice(),
["--color=always", "X"].as_slice(),
["--color=never", "X"].as_slice(),
["--rainbow", "X"].as_slice(),
["--rainbow", "--color=always", "X"].as_slice(),
] {
common::rusty_figlet_cmd().args(argv).assert().success();
}
}