use crate::adapters::server::SignedUrlSource;
use crate::{
CropRegion, Fit, MediaType, OptimizeMode, Position, Rgba8, Rotation, TargetQuality,
TransformOptions,
};
use clap::{CommandFactory, Parser, Subcommand};
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::str::FromStr;
mod convert;
mod inspect;
mod serve;
mod sign;
const MAX_REMOTE_BYTES: u64 = 32 * 1024 * 1024;
const EXIT_SUCCESS: u8 = 0;
const EXIT_USAGE: u8 = 1;
const EXIT_IO: u8 = 2;
const EXIT_INPUT: u8 = 3;
const EXIT_TRANSFORM: u8 = 4;
const EXIT_RUNTIME: u8 = 5;
fn help_top_level() -> String {
format!(
"\
truss {version} - an image transformation tool and server
Converts, resizes, and re-encodes images (JPEG, PNG, WebP, AVIF, BMP, TIFF, SVG).
Can also run as an HTTP image-transform server.
USAGE:
truss <COMMAND> [OPTIONS]
truss <INPUT> -o <OUTPUT> [OPTIONS] (implicit convert)
truss --bind <ADDR> [OPTIONS] (implicit serve)
COMMANDS:
convert Convert and transform an image file
optimize Optimize an image for smaller output size
inspect Show metadata (format, dimensions, alpha) of an image
serve Start the HTTP image-transform server
validate Check server configuration without starting the server
sign Generate a signed public URL for the server
completions Generate shell completion scripts
help Show help for a command (e.g. truss help convert)
OPTIONS:
-V, --version Print version information
EXAMPLES:
truss photo.png -o photo.jpg --width 800
truss inspect photo.jpg
truss serve --bind 0.0.0.0:8080 --storage-root /var/images
truss sign --base-url https://cdn.example.com --path /hero.jpg \\
--key-id mykey --secret s3cret --expires 1700000000
truss completions bash > ~/.local/share/bash-completion/completions/truss
Run 'truss help <command>' for more information on a specific command.
EXIT CODES:
0 Success
1 Usage error (bad arguments)
2 I/O error (file not found, permission denied, network failure)
3 Input error (unsupported format, corrupt file)
4 Transform error (encode failure, size limit exceeded, deadline)
5 Runtime error (bind failure, stdout write failure)
Sponsor: https://github.com/sponsors/nao1215
",
version = env!("CARGO_PKG_VERSION"),
)
}
const HELP_CONVERT: &str = "\
truss convert - convert and transform an image file
USAGE:
truss convert <INPUT> -o <OUTPUT> [OPTIONS]
truss convert --url <URL> -o <OUTPUT> [OPTIONS]
truss convert - -o - --format jpeg (stdin to stdout)
The 'convert' subcommand can be omitted:
truss <INPUT> -o <OUTPUT> [OPTIONS]
truss --url <URL> -o <OUTPUT> [OPTIONS]
Use -- to separate options from file paths starting with -:
truss convert -- -input.png -o out.jpg
truss convert input.png -o -- -output.jpg
OPTIONS:
-o, --output <OUTPUT> Output file path, or - for stdout (required)
--url <URL> Fetch input from an HTTP(S) URL
--width <PX> Target width in pixels
--height <PX> Target height in pixels
--fit <MODE> How to fit into target dimensions
contain: scale down to fit entirely (default)
cover: scale to fill, cropping excess
fill: stretch to exact dimensions
inside: like contain, but never upscale
--position <POS> Crop anchor for cover mode (default: center)
center, top, right, bottom, left,
top-left, top-right, bottom-left, bottom-right
--format <FMT> Output format: jpeg, png, webp, avif, bmp, tiff, svg
(default: inferred from output extension)
--quality <1-100> Encoding quality for lossy formats
--optimize <MODE> Optimization mode: none, auto, lossless, lossy
--target-quality <TARGET>
Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
--background <COLOR> Background color as RRGGBB or RRGGBBAA hex
--rotate <DEG> Rotate clockwise by whole degrees. Negative turns counter-clockwise,
and angles past a full turn wrap, so -90 and 270 are the same.
A multiple of 90 is exact; any other angle resamples and grows
the canvas to the rotated bounding box, filling the exposed
corners with --background (transparent, or white for formats
without alpha)
--auto-orient Apply EXIF orientation and reset tag (default)
--no-auto-orient Skip EXIF orientation correction
--strip-metadata Remove all metadata (default; lossy optimization keeps the
ICC profile so colors are not shifted by the re-encode)
--keep-metadata Preserve EXIF, ICC, and other supported metadata
--preserve-exif Preserve EXIF only (strip ICC and others)
--crop <x,y,w,h> Explicit crop region as x,y,width,height (applied before resize; raster-only)
--blur <SIGMA> Gaussian blur sigma (0.1-100.0; raster-only, not supported for SVG inputs)
--sharpen <SIGMA> Sharpen sigma (0.1-100.0; raster-only, not supported for SVG inputs)
--grayscale Desaturate the image to grayscale (applied after resize, blur,
and sharpen, and before the watermark)
--watermark <FILE> Watermark image to composite onto the output (raster-only, not supported for SVG inputs)
--watermark-position <POS> Watermark placement (default: bottom-right; raster-only)
center, top, right, bottom, left,
top-left, top-right, bottom-left, bottom-right
--watermark-opacity <1-100> Watermark opacity percentage (default: 50; raster-only)
--watermark-margin <PX> Margin from edge in pixels (default: 10; raster-only)
EXAMPLES:
truss photo.png -o photo.jpg --width 800
truss --url https://example.com/img.png -o out.webp --format webp --quality 75
cat photo.png | truss convert - -o - --format jpeg > photo.jpg
truss photo.png -o thumb.png --width 200 --height 200 --fit cover
truss diagram.svg -o safe.svg
truss diagram.svg -o diagram.png --width 1024
";
const HELP_OPTIMIZE: &str = "\
truss optimize - reduce image file size with format-aware optimization
USAGE:
truss optimize <INPUT> -o <OUTPUT> [OPTIONS]
truss optimize --url <URL> -o <OUTPUT> [OPTIONS]
truss optimize - -o - --format webp
OPTIONS:
-o, --output <OUTPUT> Output file path, or - for stdout (required)
--url <URL> Fetch input from an HTTP(S) URL
--format <FMT> Output format: jpeg, png, webp, avif
(default: inferred from output extension or input format)
--mode <MODE> Optimization mode: auto (default), lossless, lossy, none
--quality <1-100> Optional quality cap for lossy optimization
--target-quality <TARGET>
Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
--auto-orient Apply EXIF orientation and reset tag (default)
--no-auto-orient Skip EXIF orientation correction
--strip-metadata Remove all metadata (default; lossy optimization keeps the
ICC profile so colors are not shifted by the re-encode)
--keep-metadata Preserve EXIF, ICC, and other supported metadata
--preserve-exif Preserve EXIF only (strip ICC and others)
EXAMPLES:
truss optimize photo.jpg -o out.jpg
truss optimize photo.jpg -o out.jpg --mode lossy --target-quality ssim:0.98
truss optimize graphic.png -o out.png --mode lossless
";
const HELP_INSPECT: &str = "\
truss inspect - show metadata of an image
USAGE:
truss inspect <FILE>
truss inspect --url <URL>
truss inspect - (read from stdin)
Use -- to separate options from file paths starting with -:
truss inspect -- -weird-name.png
OUTPUT:
Prints JSON with format, MIME type, dimensions, alpha, and animation info.
EXAMPLES:
truss inspect photo.jpg
truss inspect --url https://example.com/photo.jpg
cat photo.png | truss inspect -
";
fn help_serve() -> String {
let mut s = String::from(
"\
truss serve - start the HTTP image-transform server
USAGE:
truss serve [OPTIONS]
Server flags can also be used at the top level:
truss --bind 0.0.0.0:8080 --storage-root /var/images
OPTIONS:
--bind <ADDR> Listen address (default: 127.0.0.1:8080)
--storage-root <PATH> Root directory for path-based sources
--public-base-url <URL> External base URL for signed URLs
--signed-url-key-id <KEY_ID> Key identifier for signed public URLs
--signed-url-secret <SECRET> Shared secret for HMAC verification
--allow-insecure-url-sources Allow private-network URLs (dev/test only)
ENVIRONMENT VARIABLES:
TRUSS_BIND_ADDR Listen address override
TRUSS_STORAGE_ROOT Storage root override
TRUSS_PUBLIC_BASE_URL Public base URL override
TRUSS_BEARER_TOKEN Private API authentication token
TRUSS_SIGNED_URL_KEY_ID Signing key identifier (single-key shorthand)
TRUSS_SIGNED_URL_SECRET Signing shared secret (single-key shorthand)
TRUSS_SIGNING_KEYS Multiple signing keys as JSON {\"keyId\":\"secret\",...}
TRUSS_ALLOW_INSECURE_URL_SOURCES Enable insecure URL sources
TRUSS_CACHE_ROOT On-disk transform cache directory
TRUSS_PRESETS Named transform presets as inline JSON
TRUSS_PRESETS_FILE Path to a JSON file containing named transform presets
",
);
{
#[allow(unused_mut, clippy::useless_vec)]
let mut backends = vec!["filesystem (default)"];
#[cfg(feature = "s3")]
backends.push("s3");
#[cfg(feature = "gcs")]
backends.push("gcs");
#[cfg(feature = "azure")]
backends.push("azure");
s.push_str(&format!(
" TRUSS_STORAGE_BACKEND Source for public by-path resolution: {}\n",
backends.join(", ")
));
}
#[cfg(feature = "s3")]
s.push_str(
"\
TRUSS_S3_BUCKET Default S3 bucket name (required when backend=s3)
TRUSS_S3_FORCE_PATH_STYLE Use path-style S3 addressing (set to 1/true/yes/on for MinIO, etc.)
AWS_ACCESS_KEY_ID AWS access key for S3 authentication
AWS_SECRET_ACCESS_KEY AWS secret key for S3 authentication
AWS_REGION AWS region for the S3 client (e.g. us-east-1)
AWS_ENDPOINT_URL Custom S3-compatible endpoint URL (e.g. http://minio:9000)
",
);
#[cfg(feature = "gcs")]
s.push_str(
"\
TRUSS_GCS_BUCKET Default GCS bucket name (required when backend=gcs)
TRUSS_GCS_ENDPOINT Custom GCS endpoint URL (for testing with fake-gcs-server, etc.)
GOOGLE_APPLICATION_CREDENTIALS Path to GCS service account JSON key file
",
);
#[cfg(feature = "azure")]
s.push_str(
"\
TRUSS_AZURE_CONTAINER Default Azure container name (required when backend=azure)
TRUSS_AZURE_ENDPOINT Custom Azure Blob endpoint URL (for Azurite, etc.)
AZURE_STORAGE_ACCOUNT_NAME Storage account name (derives endpoint when TRUSS_AZURE_ENDPOINT is unset)
",
);
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
s.push_str(
"\
TRUSS_STORAGE_TIMEOUT_SECS Download timeout for storage backends in seconds (default: 30, range: 1-300)
",
);
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
s.push_str(
"\
\nNOTE: When using local emulators (MinIO, fake-gcs-server, Azurite), set
TRUSS_ALLOW_INSECURE_URL_SOURCES=true to allow plain-HTTP endpoints.
",
);
s.push_str(
"\
\nEXAMPLES:
truss serve --bind 0.0.0.0:8080 --storage-root /var/images
truss serve --bind 127.0.0.1:3000 --signed-url-key-id mykey --signed-url-secret s3cret
",
);
s
}
const HELP_SIGN: &str = "\
truss sign - generate a signed public URL
USAGE:
truss sign --base-url <URL> --path <PATH> \\
--key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
truss sign --base-url <URL> --url <URL> \\
--key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
REQUIRED:
--base-url <URL> CDN base URL for the signed request
--path <PATH> Image path on the server (mutually exclusive with --url)
--url <URL> Remote image URL to transform (mutually exclusive with --path)
--key-id <KEY_ID> Signing key identifier
--secret <SECRET> HMAC shared secret
--expires <UNIX_SECS> Expiration as Unix timestamp
OPTIONAL:
--version <VALUE> Cache-busting version tag
--width, --height, --fit, --position, --format, --quality,
--optimize, --target-quality, --background, --rotate, --auto-orient, --no-auto-orient,
--strip-metadata, --keep-metadata, --preserve-exif, --crop, --blur, --sharpen,
--grayscale
--watermark-url <URL> Watermark image URL to embed in the signed URL
--watermark-position <POS> Watermark placement (default: bottom-right)
--watermark-opacity <1-100> Watermark opacity (default: 50)
--watermark-margin <PX> Watermark margin from edge in pixels (default: 10)
--preset <NAME> Named transform preset (server-side)
EXAMPLES:
truss sign --base-url https://cdn.example.com \\
--path /photos/hero.jpg --key-id mykey --secret s3cret \\
--expires 1700000000 --width 640 --format webp
";
const HELP_VALIDATE: &str = "\
truss validate - check server configuration without starting the server
USAGE:
truss validate
Parses and validates all environment variables used by `truss serve`.
Exits 0 when the configuration is valid, or exits 1 with a description
of each error found.
Useful in CI/CD pipelines to catch configuration mistakes early.
";
const HELP_COMPLETIONS: &str = "\
truss completions - generate shell completion scripts
USAGE:
truss completions <SHELL>
SHELLS:
bash, zsh, fish, elvish, powershell
EXAMPLES:
truss completions bash > ~/.local/share/bash-completion/completions/truss
truss completions zsh > ~/.zfunc/_truss
truss completions fish > ~/.config/fish/completions/truss.fish
";
const HELP_VERSION: &str = "\
truss version - print version information
USAGE:
truss version
truss -V
truss --version
";
#[derive(Parser)]
#[command(
name = "truss",
about = "an image transformation tool and server",
disable_help_subcommand = true,
disable_help_flag = true,
disable_version_flag = true
)]
struct Cli {
#[command(subcommand)]
command: Option<CliSubcommand>,
}
#[derive(Subcommand)]
enum CliSubcommand {
#[command(disable_help_flag = true)]
Convert(ClapConvertArgs),
#[command(disable_help_flag = true)]
Optimize(ClapOptimizeArgs),
#[command(disable_help_flag = true)]
Inspect(ClapInspectArgs),
#[command(disable_help_flag = true)]
Serve(ClapServeArgs),
#[command(disable_help_flag = true)]
Sign(ClapSignArgs),
Help { topic: Option<String> },
Version,
#[command(disable_help_flag = true)]
Validate(ClapValidateArgs),
#[command(disable_help_flag = true)]
Completions {
#[arg(value_enum)]
shell: Option<clap_complete::Shell>,
#[arg(long)]
help: bool,
},
}
#[derive(clap::Args)]
struct ClapConvertArgs {
#[arg(allow_hyphen_values = true)]
input: Option<String>,
#[arg(short = 'o', long = "output", allow_hyphen_values = true)]
output: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
width: Option<u32>,
#[arg(long)]
height: Option<u32>,
#[arg(long, value_parser = parse_fit)]
fit: Option<Fit>,
#[arg(long, value_parser = parse_position)]
position: Option<Position>,
#[arg(long, value_parser = parse_media_type)]
format: Option<MediaType>,
#[arg(long)]
quality: Option<u8>,
#[arg(long, value_parser = parse_optimize_mode)]
optimize: Option<OptimizeMode>,
#[arg(long = "target-quality", value_parser = parse_target_quality)]
target_quality: Option<TargetQuality>,
#[arg(long, value_parser = parse_background)]
background: Option<Rgba8>,
#[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
rotate: Option<Rotation>,
#[arg(long)]
auto_orient: bool,
#[arg(long)]
no_auto_orient: bool,
#[arg(long)]
strip_metadata: bool,
#[arg(long)]
keep_metadata: bool,
#[arg(long)]
preserve_exif: bool,
#[arg(long, value_parser = parse_crop)]
crop: Option<CropRegion>,
#[arg(long, value_parser = parse_blur)]
blur: Option<f32>,
#[arg(long, value_parser = parse_sharpen)]
sharpen: Option<f32>,
#[arg(long)]
grayscale: bool,
#[arg(long)]
watermark: Option<PathBuf>,
#[arg(long, value_parser = parse_position)]
watermark_position: Option<Position>,
#[arg(long, value_parser = parse_watermark_opacity)]
watermark_opacity: Option<u8>,
#[arg(long)]
watermark_margin: Option<u32>,
#[arg(short = 'h', long = "help")]
help: bool,
}
#[derive(clap::Args)]
struct ClapOptimizeArgs {
#[arg(allow_hyphen_values = true)]
input: Option<String>,
#[arg(short = 'o', long = "output", allow_hyphen_values = true)]
output: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long, value_parser = parse_optimizable_media_type)]
format: Option<MediaType>,
#[arg(long)]
quality: Option<u8>,
#[arg(long = "mode", value_parser = parse_optimize_mode)]
mode: Option<OptimizeMode>,
#[arg(long = "target-quality", value_parser = parse_target_quality)]
target_quality: Option<TargetQuality>,
#[arg(long)]
auto_orient: bool,
#[arg(long)]
no_auto_orient: bool,
#[arg(long)]
strip_metadata: bool,
#[arg(long)]
keep_metadata: bool,
#[arg(long)]
preserve_exif: bool,
#[arg(short = 'h', long = "help")]
help: bool,
}
#[derive(clap::Args)]
struct ClapInspectArgs {
#[arg(allow_hyphen_values = true)]
input: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(short = 'h', long = "help")]
help: bool,
}
#[derive(clap::Args)]
struct ClapServeArgs {
#[arg(long)]
bind: Option<String>,
#[arg(long)]
storage_root: Option<PathBuf>,
#[arg(long, value_parser = parse_url_value)]
public_base_url: Option<String>,
#[arg(long)]
signed_url_key_id: Option<String>,
#[arg(long)]
signed_url_secret: Option<String>,
#[arg(long)]
allow_insecure_url_sources: bool,
#[arg(short = 'h', long = "help")]
help: bool,
}
#[derive(clap::Args)]
struct ClapValidateArgs {
#[arg(short = 'h', long = "help")]
help: bool,
}
#[derive(clap::Args)]
struct ClapSignArgs {
#[arg(long, value_parser = parse_url_value)]
base_url: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(long, value_parser = parse_url_value)]
url: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(long)]
key_id: Option<String>,
#[arg(long)]
secret: Option<String>,
#[arg(long)]
expires: Option<u64>,
#[arg(long)]
width: Option<u32>,
#[arg(long)]
height: Option<u32>,
#[arg(long, value_parser = parse_fit)]
fit: Option<Fit>,
#[arg(long, value_parser = parse_position)]
position: Option<Position>,
#[arg(long, value_parser = parse_media_type)]
format: Option<MediaType>,
#[arg(long)]
quality: Option<u8>,
#[arg(long, value_parser = parse_optimize_mode)]
optimize: Option<OptimizeMode>,
#[arg(long = "target-quality", value_parser = parse_target_quality)]
target_quality: Option<TargetQuality>,
#[arg(long, value_parser = parse_background)]
background: Option<Rgba8>,
#[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
rotate: Option<Rotation>,
#[arg(long)]
auto_orient: bool,
#[arg(long)]
no_auto_orient: bool,
#[arg(long)]
strip_metadata: bool,
#[arg(long)]
keep_metadata: bool,
#[arg(long)]
preserve_exif: bool,
#[arg(long, value_parser = parse_crop)]
crop: Option<CropRegion>,
#[arg(long, value_parser = parse_blur)]
blur: Option<f32>,
#[arg(long, value_parser = parse_sharpen)]
sharpen: Option<f32>,
#[arg(long)]
grayscale: bool,
#[arg(long, value_parser = parse_url_value)]
watermark_url: Option<String>,
#[arg(long, value_parser = parse_position)]
watermark_position: Option<Position>,
#[arg(long, value_parser = parse_watermark_opacity)]
watermark_opacity: Option<u8>,
#[arg(long)]
watermark_margin: Option<u32>,
#[arg(long)]
preset: Option<String>,
#[arg(short = 'h', long = "help")]
help: bool,
}
fn parse_fit(s: &str) -> Result<Fit, String> {
Fit::from_str(s)
}
fn parse_position(s: &str) -> Result<Position, String> {
Position::from_str(s)
}
fn parse_media_type(s: &str) -> Result<MediaType, String> {
let media_type = MediaType::from_str(s)?;
if media_type.is_encodable() {
Ok(media_type)
} else {
Err(format!(
"{} is an input-only format; choose an output format such as png, jpeg, webp, or avif",
media_type.as_name()
))
}
}
fn parse_optimizable_media_type(s: &str) -> Result<MediaType, String> {
let media_type = parse_media_type(s)?;
if media_type.supports_optimization() {
Ok(media_type)
} else {
Err(format!(
"optimization is not supported for {} output",
media_type.as_name()
))
}
}
fn parse_optimize_mode(s: &str) -> Result<OptimizeMode, String> {
OptimizeMode::from_str(s)
}
fn parse_target_quality(s: &str) -> Result<TargetQuality, String> {
TargetQuality::from_str(s)
}
fn parse_rotation(s: &str) -> Result<Rotation, String> {
Rotation::from_str(s)
}
fn parse_background(s: &str) -> Result<Rgba8, String> {
Rgba8::from_hex(s)
}
fn parse_crop(s: &str) -> Result<CropRegion, String> {
CropRegion::from_str(s)
}
fn parse_blur(s: &str) -> Result<f32, String> {
let v: f32 = s
.parse()
.map_err(|_| format!("invalid blur value: '{s}'"))?;
if !v.is_finite() || !(0.1..=100.0).contains(&v) {
return Err("blur must be between 0.1 and 100.0".to_string());
}
Ok(v)
}
fn parse_sharpen(s: &str) -> Result<f32, String> {
let v: f32 = s
.parse()
.map_err(|_| format!("invalid sharpen value: '{s}'"))?;
if !v.is_finite() || !(0.1..=100.0).contains(&v) {
return Err("sharpen must be between 0.1 and 100.0".to_string());
}
Ok(v)
}
fn parse_watermark_opacity(s: &str) -> Result<u8, String> {
let v: u8 = s
.parse()
.map_err(|_| format!("invalid watermark opacity: '{s}'"))?;
if !(1..=100).contains(&v) {
return Err("watermark opacity must be between 1 and 100".to_string());
}
Ok(v)
}
fn parse_url_value(s: &str) -> Result<String, String> {
let parsed = url::Url::parse(s).map_err(|e| format!("invalid URL: {e}"))?;
match parsed.scheme() {
"http" | "https" => Ok(s.to_string()),
_ => Err(format!("requires an http:// or https:// URL, got '{s}'")),
}
}
fn convert_usage() -> &'static str {
"usage: truss convert <INPUT> -o <OUTPUT> [OPTIONS]"
}
fn optimize_usage() -> &'static str {
"usage: truss optimize <INPUT> -o <OUTPUT> [OPTIONS]"
}
fn inspect_usage() -> &'static str {
"usage: truss inspect <FILE|--url URL|->"
}
fn serve_usage() -> &'static str {
"usage: truss serve [--bind ADDR] [--storage-root PATH] [OPTIONS]"
}
fn sign_usage() -> &'static str {
"usage: truss sign --base-url <URL> (--path <PATH>|--url <URL>) --key-id <ID> --secret <SECRET> --expires <UNIX_SECS>"
}
pub fn run<I>(args: I) -> ExitCode
where
I: IntoIterator<Item = String>,
{
let stdin = io::stdin();
let stdout = io::stdout();
let stderr = io::stderr();
let mut stdin = stdin.lock();
let mut stdout = stdout.lock();
let mut stderr = stderr.lock();
ExitCode::from(run_with_io(args, &mut stdin, &mut stdout, &mut stderr))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum HelpTopic {
TopLevel,
Convert,
Optimize,
Inspect,
Serve,
Validate,
Sign,
Completions,
Version,
}
#[derive(Debug, Clone, PartialEq)]
enum Command {
Help(HelpTopic),
Version,
Serve(ServeCommand),
Validate,
Inspect(InspectCommand),
Convert(ConvertCommand),
Optimize(ConvertCommand),
Sign(SignCommand),
Completions(clap_complete::Shell),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ServeCommand {
bind_addr: Option<String>,
storage_root: Option<PathBuf>,
public_base_url: Option<String>,
signed_url_key_id: Option<String>,
signed_url_secret: Option<String>,
allow_insecure_url_sources: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InspectCommand {
input: InputSource,
}
#[derive(Debug, Clone, PartialEq)]
struct ConvertCommand {
input: InputSource,
output: OutputTarget,
options: TransformOptions,
watermark_path: Option<PathBuf>,
watermark_position: Option<Position>,
watermark_opacity: Option<u8>,
watermark_margin: Option<u32>,
}
#[derive(Debug, Clone, PartialEq)]
struct SignCommand {
base_url: String,
source: SignedUrlSource,
key_id: String,
secret: String,
expires: u64,
options: TransformOptions,
watermark_url: Option<String>,
watermark_position: Option<Position>,
watermark_opacity: Option<u8>,
watermark_margin: Option<u32>,
preset: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InputSource {
Stdin,
Path(PathBuf),
Url(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum OutputTarget {
Stdout,
Path(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CliError {
exit_code: u8,
message: String,
usage: Option<String>,
hint: Option<String>,
}
fn run_with_io<I, R, W, E>(args: I, stdin: &mut R, stdout: &mut W, stderr: &mut E) -> u8
where
I: IntoIterator<Item = String>,
R: Read,
W: Write,
E: Write,
{
match parse_args(args) {
Ok(Command::Help(topic)) => {
let text = match topic {
HelpTopic::TopLevel => help_top_level(),
HelpTopic::Convert => HELP_CONVERT.to_string(),
HelpTopic::Optimize => HELP_OPTIMIZE.to_string(),
HelpTopic::Inspect => HELP_INSPECT.to_string(),
HelpTopic::Serve => help_serve(),
HelpTopic::Validate => HELP_VALIDATE.to_string(),
HelpTopic::Sign => HELP_SIGN.to_string(),
HelpTopic::Completions => HELP_COMPLETIONS.to_string(),
HelpTopic::Version => HELP_VERSION.to_string(),
};
match stdout.write_all(text.as_bytes()) {
Ok(_) => EXIT_SUCCESS,
Err(_) => EXIT_RUNTIME,
}
}
Ok(Command::Version) => match writeln!(stdout, "truss {}", env!("CARGO_PKG_VERSION")) {
Ok(_) => EXIT_SUCCESS,
Err(_) => EXIT_RUNTIME,
},
Ok(Command::Serve(command)) => match serve::execute_serve(command) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
},
Ok(Command::Validate) => match serve::execute_validate(stdout) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
},
Ok(Command::Inspect(command)) => match inspect::execute_inspect(command, stdin, stdout) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
},
Ok(Command::Convert(command) | Command::Optimize(command)) => {
match convert::execute_convert(command, stdin, stdout) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
}
}
Ok(Command::Sign(command)) => match sign::execute_sign(command, stdout) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
},
Ok(Command::Completions(shell)) => match generate_completions(shell, stdout) {
Ok(()) => EXIT_SUCCESS,
Err(error) => write_error(stderr, error),
},
Err(error) => write_error(stderr, error),
}
}
const KNOWN_SUBCOMMANDS: &[&str] = &[
"convert",
"optimize",
"inspect",
"serve",
"validate",
"sign",
"help",
"completions",
"version",
];
fn is_serve_flag(value: &str) -> bool {
matches!(
value,
"--bind"
| "--storage-root"
| "--public-base-url"
| "--signed-url-key-id"
| "--signed-url-secret"
| "--allow-insecure-url-sources"
)
}
fn looks_like_unknown_subcommand(value: &str) -> bool {
if value.starts_with('-') || value.starts_with('/') || value.starts_with('.') {
return false;
}
if value.contains('.') || value.contains('/') || value.contains('\\') {
return false;
}
value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn preprocess_args(args: Vec<String>) -> Vec<String> {
if args.len() <= 1 {
return args;
}
let first = &args[1];
if first == "-h" || first == "--help" {
let mut new = vec![args[0].clone(), "help".to_string()];
if args.len() > 2 {
new.extend_from_slice(&args[2..]);
}
return new;
}
if first == "-V" || first == "--version" {
return vec![args[0].clone(), "version".to_string()];
}
if is_serve_flag(first) {
let mut new = vec![args[0].clone(), "serve".to_string()];
new.extend_from_slice(&args[1..]);
return new;
}
if KNOWN_SUBCOMMANDS.contains(&first.as_str()) {
return args;
}
if std::path::Path::new(first).is_file() {
let mut new = vec![args[0].clone(), "convert".to_string()];
new.extend_from_slice(&args[1..]);
return new;
}
if looks_like_unknown_subcommand(first) {
return args;
}
let mut new = vec![args[0].clone(), "convert".to_string()];
new.extend_from_slice(&args[1..]);
new
}
fn parse_args<I>(args: I) -> Result<Command, CliError>
where
I: IntoIterator<Item = String>,
{
let raw: Vec<String> = args.into_iter().collect();
if raw.len() <= 1 {
return Ok(Command::Help(HelpTopic::TopLevel));
}
let preprocessed = preprocess_args(raw);
let cli = Cli::try_parse_from(&preprocessed).map_err(map_clap_error)?;
match cli.command {
None => Ok(Command::Help(HelpTopic::TopLevel)),
Some(CliSubcommand::Help { topic }) => parse_help_topic(topic),
Some(CliSubcommand::Version) => Ok(Command::Version),
Some(CliSubcommand::Completions { help: true, .. }) => {
Ok(Command::Help(HelpTopic::Completions))
}
Some(CliSubcommand::Completions {
shell: Some(shell), ..
}) => Ok(Command::Completions(shell)),
Some(CliSubcommand::Completions {
shell: None,
help: false,
}) => Err(CliError {
exit_code: EXIT_USAGE,
message: "'completions' requires a shell argument".to_string(),
usage: None,
hint: Some("try 'truss completions bash'".to_string()),
}),
Some(CliSubcommand::Convert(args)) => convert::convert_from_clap(args),
Some(CliSubcommand::Optimize(args)) => convert::optimize_from_clap(args),
Some(CliSubcommand::Inspect(args)) => inspect::inspect_from_clap(args),
Some(CliSubcommand::Serve(args)) => serve::serve_from_clap(args),
Some(CliSubcommand::Validate(args)) => serve::validate_from_clap(args),
Some(CliSubcommand::Sign(args)) => sign::sign_from_clap(args),
}
}
fn map_clap_error(err: clap::Error) -> CliError {
let raw = err.to_string();
let message = raw
.strip_prefix("error: ")
.unwrap_or(&raw)
.trim()
.to_string();
CliError {
exit_code: EXIT_USAGE,
message,
usage: None,
hint: Some("run 'truss --help' for available commands".to_string()),
}
}
fn parse_help_topic(topic: Option<String>) -> Result<Command, CliError> {
match topic.as_deref() {
None => Ok(Command::Help(HelpTopic::TopLevel)),
Some("convert") => Ok(Command::Help(HelpTopic::Convert)),
Some("optimize") => Ok(Command::Help(HelpTopic::Optimize)),
Some("inspect") => Ok(Command::Help(HelpTopic::Inspect)),
Some("serve") => Ok(Command::Help(HelpTopic::Serve)),
Some("validate") => Ok(Command::Help(HelpTopic::Validate)),
Some("sign") => Ok(Command::Help(HelpTopic::Sign)),
Some("completions") => Ok(Command::Help(HelpTopic::Completions)),
Some("version") => Ok(Command::Help(HelpTopic::Version)),
Some(other) => Err(CliError {
exit_code: EXIT_USAGE,
message: format!("unknown help topic '{other}'"),
usage: None,
hint: Some(
"available topics: convert, optimize, inspect, serve, validate, sign, completions, version"
.to_string(),
),
}),
}
}
struct TransformFields {
width: Option<u32>,
height: Option<u32>,
fit: Option<Fit>,
position: Option<Position>,
format: Option<MediaType>,
quality: Option<u8>,
optimize: Option<OptimizeMode>,
target_quality: Option<TargetQuality>,
background: Option<Rgba8>,
rotate: Option<Rotation>,
auto_orient: bool,
no_auto_orient: bool,
strip_metadata: bool,
keep_metadata: bool,
preserve_exif: bool,
crop: Option<CropRegion>,
blur: Option<f32>,
sharpen: Option<f32>,
grayscale: bool,
}
impl TransformFields {
fn into_options(self) -> Result<TransformOptions, crate::TransformError> {
let defaults = TransformOptions::default();
let auto_orient = if self.no_auto_orient {
false
} else if self.auto_orient {
true
} else {
defaults.auto_orient
};
let (strip_metadata, preserve_exif) = crate::resolve_metadata_flags(
if self.strip_metadata {
Some(true)
} else {
None
},
if self.keep_metadata { Some(true) } else { None },
if self.preserve_exif { Some(true) } else { None },
)?;
Ok(TransformOptions {
width: self.width,
height: self.height,
fit: self.fit,
position: self.position,
format: self.format,
quality: self.quality,
optimize: self.optimize.unwrap_or(defaults.optimize),
target_quality: self.target_quality,
background: self.background,
rotate: self.rotate.unwrap_or(defaults.rotate),
auto_orient,
strip_metadata,
preserve_exif,
crop: self.crop,
blur: self.blur,
sharpen: self.sharpen,
grayscale: self.grayscale,
deadline: None,
})
}
}
fn validate_url(url: &str, flag: &str) -> Result<(), CliError> {
let parsed = url::Url::parse(url).map_err(|e| CliError {
exit_code: EXIT_USAGE,
message: format!("'{flag}' is not a valid URL: {e}"),
usage: None,
hint: Some(format!("got '{url}'")),
})?;
match parsed.scheme() {
"http" | "https" => Ok(()),
_ => Err(CliError {
exit_code: EXIT_USAGE,
message: format!("'{flag}' requires an http:// or https:// URL"),
usage: None,
hint: Some(format!("got '{url}'")),
}),
}
}
fn generate_completions<W: Write>(
shell: clap_complete::Shell,
stdout: &mut W,
) -> Result<(), CliError> {
let mut cmd = Cli::command();
cmd = cmd
.arg(
clap::Arg::new("INPUT")
.help("Input image file (implicit convert)")
.value_hint(clap::ValueHint::FilePath),
)
.arg(
clap::Arg::new("output")
.short('o')
.long("output")
.help("Output file path (implicit convert)")
.value_hint(clap::ValueHint::FilePath),
)
.arg(
clap::Arg::new("bind")
.long("bind")
.help("Listen address (implicit serve)"),
)
.arg(
clap::Arg::new("storage-root")
.long("storage-root")
.help("Root directory for path-based sources (implicit serve)")
.value_hint(clap::ValueHint::DirPath),
);
clap_complete::generate(shell, &mut cmd, "truss", stdout);
Ok(())
}
fn read_input_bytes<R>(input: InputSource, stdin: &mut R) -> Result<Vec<u8>, CliError>
where
R: Read,
{
match input {
InputSource::Stdin => {
let mut bytes = Vec::new();
stdin.read_to_end(&mut bytes).map_err(|error| {
runtime_error(EXIT_IO, &format!("failed to read stdin: {error}"))
})?;
Ok(bytes)
}
InputSource::Path(path) => fs::read(&path).map_err(|error| {
runtime_error(
EXIT_IO,
&format!("failed to read {}: {error}", path.display()),
)
}),
InputSource::Url(url) => read_url_bytes(&url),
}
}
const CLI_FETCH_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const CLI_FETCH_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
fn read_url_bytes(url: &str) -> Result<Vec<u8>, CliError> {
let config = ureq::config::Config::builder()
.timeout_connect(Some(CLI_FETCH_CONNECT_TIMEOUT))
.timeout_recv_body(Some(CLI_FETCH_BODY_TIMEOUT))
.http_status_as_error(false)
.build();
let agent = ureq::Agent::new_with_config(config);
let response = agent
.get(url)
.call()
.map_err(|error| runtime_error(EXIT_IO, &format!("failed to fetch {url}: {error}")))?;
let status = response.status().as_u16();
if status >= 400 {
return Err(runtime_error(
EXIT_IO,
&format!("failed to fetch {url}: HTTP {status}"),
));
}
if response
.headers()
.get("Content-Length")
.and_then(|v: &ureq::http::HeaderValue| v.to_str().ok())
.and_then(|value: &str| value.parse::<u64>().ok())
.is_some_and(|len| len > MAX_REMOTE_BYTES)
{
return Err(runtime_error(
EXIT_IO,
&format!("failed to fetch {url}: response exceeds {MAX_REMOTE_BYTES} bytes"),
));
}
let mut reader = response
.into_body()
.into_reader()
.take(MAX_REMOTE_BYTES + 1);
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|error| runtime_error(EXIT_IO, &format!("failed to fetch {url}: {error}")))?;
if bytes.len() as u64 > MAX_REMOTE_BYTES {
return Err(runtime_error(
EXIT_IO,
&format!("failed to fetch {url}: response exceeds {MAX_REMOTE_BYTES} bytes"),
));
}
Ok(bytes)
}
fn map_transform_error(error: crate::TransformError) -> CliError {
match error {
crate::TransformError::InvalidOptions(reason) => runtime_error(EXIT_USAGE, &reason),
crate::TransformError::InvalidInput(reason) => runtime_error(EXIT_INPUT, &reason),
crate::TransformError::UnsupportedInputMediaType(reason) => {
runtime_error(EXIT_INPUT, &reason)
}
crate::TransformError::DecodeFailed(reason)
| crate::TransformError::EncodeFailed(reason)
| crate::TransformError::CapabilityMissing(reason)
| crate::TransformError::LimitExceeded(reason) => runtime_error(EXIT_TRANSFORM, &reason),
crate::TransformError::UnsupportedOutputMediaType(media_type) => runtime_error(
EXIT_TRANSFORM,
&format!("unsupported output media type: {media_type}"),
),
}
}
fn convert_error(message: &str) -> CliError {
CliError {
exit_code: EXIT_USAGE,
message: message.to_string(),
usage: Some(convert_usage().to_string()),
hint: Some("run 'truss convert --help' for convert options".to_string()),
}
}
fn optimize_error(message: &str) -> CliError {
CliError {
exit_code: EXIT_USAGE,
message: message.to_string(),
usage: Some(optimize_usage().to_string()),
hint: Some("run 'truss optimize --help' for optimize options".to_string()),
}
}
fn sign_error(message: &str) -> CliError {
CliError {
exit_code: EXIT_USAGE,
message: message.to_string(),
usage: Some(sign_usage().to_string()),
hint: Some("run 'truss sign --help' for sign options".to_string()),
}
}
fn runtime_error(exit_code: u8, message: &str) -> CliError {
CliError {
exit_code,
message: message.to_string(),
usage: None,
hint: None,
}
}
fn write_error<E>(stderr: &mut E, error: CliError) -> u8
where
E: Write,
{
let _ = writeln!(stderr, "error: {}", error.message);
if let Some(usage) = &error.usage {
let _ = writeln!(stderr, "{usage}");
}
if let Some(hint) = &error.hint {
let _ = writeln!(stderr, "hint: {hint}");
}
error.exit_code
}
#[cfg(test)]
mod tests {
use super::serve::resolve_server_config;
use super::{
Command, ConvertCommand, HelpTopic, InputSource, OutputTarget, ServeCommand, SignCommand,
parse_args, preprocess_args, run_with_io,
};
use crate::{
Fit, MediaType, OptimizeMode, RawArtifact, SignedUrlSource, TransformOptions,
sniff_artifact,
};
use image::codecs::png::PngEncoder;
use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
use serial_test::serial;
use std::env;
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
fn png_bytes() -> Vec<u8> {
let image = RgbaImage::from_pixel(4, 3, Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&image, 4, 3, ColorType::Rgba8.into())
.expect("encode png");
bytes
}
fn temp_file_path(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("current time")
.as_nanos();
env::temp_dir().join(format!("truss-{name}-{unique}.bin"))
}
fn temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("current time")
.as_nanos();
let path = env::temp_dir().join(format!("truss-{name}-{unique}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn spawn_http_server(
body: Vec<u8>,
content_type: &'static str,
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind http test server");
let addr = listener.local_addr().expect("server addr");
let url = format!("http://{addr}/image");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(header.as_bytes()).expect("write headers");
stream.write_all(&body).expect("write body");
stream.flush().expect("flush response");
});
(url, handle)
}
#[test]
fn bare_invocation_shows_top_level_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("COMMANDS:"));
assert!(output.contains("convert"));
assert!(output.contains("inspect"));
assert!(output.contains("serve"));
assert!(output.contains("sign"));
}
#[test]
fn dash_dash_help_shows_top_level_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "--help".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("COMMANDS:"));
assert!(output.contains("EXIT CODES:"));
}
#[test]
fn help_command_shows_top_level_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "help".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("COMMANDS:"));
}
#[test]
fn help_convert_shows_convert_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"help".to_string(),
"convert".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss convert"));
assert!(output.contains("--output"));
assert!(output.contains("--width"));
assert!(!output.contains("--bind")); }
#[test]
fn help_optimize_shows_optimize_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"help".to_string(),
"optimize".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss optimize"));
assert!(output.contains("--mode"));
assert!(output.contains("--target-quality"));
}
#[test]
fn convert_dash_help_shows_convert_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"--help".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss convert"));
assert!(output.contains("--output"));
}
#[test]
fn serve_dash_help_shows_serve_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"serve".to_string(),
"--help".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss serve"));
assert!(output.contains("--bind"));
assert!(output.contains("--storage-root"));
assert!(output.contains("ENVIRONMENT VARIABLES:"));
assert!(!output.contains("--width")); }
#[test]
fn sign_dash_help_shows_sign_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"sign".to_string(),
"--help".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss sign"));
assert!(output.contains("--base-url"));
assert!(output.contains("--key-id"));
assert!(output.contains("--expires"));
}
#[test]
fn convert_missing_output_shows_usage_and_hint() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"input.png".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 1);
let output = String::from_utf8(stderr).expect("utf8 stderr");
assert!(output.contains("error:"), "should contain error: {output}");
assert!(output.contains("usage:"), "should contain usage: {output}");
assert!(output.contains("hint:"), "should contain hint: {output}");
}
#[test]
fn inspect_missing_input_shows_usage_and_hint() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "inspect".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 1);
let output = String::from_utf8(stderr).expect("utf8 stderr");
assert!(output.contains("error:"), "should contain error: {output}");
assert!(output.contains("usage:"), "should contain usage: {output}");
assert!(output.contains("hint:"), "should contain hint: {output}");
}
#[test]
fn sign_missing_args_shows_usage_and_hint() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "sign".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 1);
let output = String::from_utf8(stderr).expect("utf8 stderr");
assert!(output.contains("error:"), "should contain error: {output}");
assert!(output.contains("usage:"), "should contain usage: {output}");
assert!(output.contains("hint:"), "should contain hint: {output}");
}
#[test]
fn double_dash_allows_leading_dash_input() {
let result = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"-o".to_string(),
"out.jpg".to_string(),
"--".to_string(),
"-foo.png".to_string(),
]);
assert_eq!(
result.unwrap(),
Command::Convert(ConvertCommand {
input: InputSource::Path(PathBuf::from("-foo.png")),
output: OutputTarget::Path(PathBuf::from("out.jpg")),
options: TransformOptions::default(),
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn top_level_serve_flags_parse_correctly() {
let command = parse_args(vec![
"truss".to_string(),
"--storage-root".to_string(),
"fixtures".to_string(),
"--public-base-url".to_string(),
"https://assets.example.com".to_string(),
"--allow-insecure-url-sources".to_string(),
])
.expect("parse implicit serve");
assert_eq!(
command,
Command::Serve(ServeCommand {
bind_addr: None,
storage_root: Some(PathBuf::from("fixtures")),
public_base_url: Some("https://assets.example.com".to_string()),
signed_url_key_id: None,
signed_url_secret: None,
allow_insecure_url_sources: true,
})
);
}
#[test]
fn implicit_convert_still_works() {
let command = parse_args(vec![
"truss".to_string(),
"input.png".to_string(),
"-o".to_string(),
"output.jpg".to_string(),
"--width".to_string(),
"100".to_string(),
"--fit".to_string(),
"contain".to_string(),
])
.expect("parse implicit convert");
assert_eq!(
command,
Command::Convert(ConvertCommand {
input: InputSource::Path(PathBuf::from("input.png")),
output: OutputTarget::Path(PathBuf::from("output.jpg")),
options: TransformOptions {
width: Some(100),
fit: Some(Fit::Contain),
..TransformOptions::default()
},
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn exit_code_help_is_zero() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec!["truss".to_string(), "--help".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 0);
}
#[test]
fn exit_code_usage_error_is_one() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"input.png".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 1);
}
#[test]
fn exit_code_io_error() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec![
"truss".to_string(),
"inspect".to_string(),
"missing-file.png".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 2);
}
#[test]
fn exit_code_input_error() {
let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 3);
}
#[test]
fn unknown_subcommand_exits_with_usage_error() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "converrt".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 1);
let output = String::from_utf8(stderr).expect("utf8 stderr");
assert!(output.contains("error:"), "should contain error: {output}");
}
#[test]
fn inspect_dash_help_shows_inspect_help() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"inspect".to_string(),
"--help".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("truss inspect"));
assert!(output.contains("--url"));
}
#[test]
fn help_inspect_shows_inspect_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"inspect".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Inspect));
}
#[test]
fn help_serve_shows_serve_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"serve".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Serve));
}
#[test]
fn help_validate_shows_validate_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"validate".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
}
#[test]
fn parse_args_validate() {
let result =
parse_args(vec!["truss".to_string(), "validate".to_string()]).expect("parse validate");
assert_eq!(result, Command::Validate);
}
#[test]
fn validate_help_flag() {
let result = parse_args(vec![
"truss".to_string(),
"validate".to_string(),
"--help".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
}
#[test]
#[serial]
fn validate_invalid_config() {
unsafe { env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "invalid") };
let mut stdout = Vec::new();
let result = super::serve::execute_validate(&mut stdout);
unsafe { env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS") };
assert!(result.is_err());
}
#[test]
#[serial]
fn validate_valid_config() {
let dir = tempfile::tempdir().expect("create temp dir");
let mut stdout = Vec::new();
unsafe { env::set_var("TRUSS_STORAGE_ROOT", dir.path().to_str().unwrap()) };
let result = super::serve::execute_validate(&mut stdout);
unsafe { env::remove_var("TRUSS_STORAGE_ROOT") };
assert!(result.is_ok());
let output = String::from_utf8(stdout).expect("valid utf-8");
assert!(output.contains("configuration is valid"));
assert!(output.contains("storage root:"));
}
#[test]
fn help_sign_shows_sign_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"sign".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Sign));
}
#[test]
fn dash_h_shows_top_level_help() {
let result = parse_args(vec!["truss".to_string(), "-h".to_string()]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::TopLevel));
}
#[test]
fn parse_args_supports_serve_bind() {
let command = parse_args(vec![
"truss".to_string(),
"serve".to_string(),
"--bind".to_string(),
"127.0.0.1:9000".to_string(),
])
.expect("parse serve bind");
assert_eq!(
command,
Command::Serve(ServeCommand {
bind_addr: Some("127.0.0.1:9000".to_string()),
storage_root: None,
public_base_url: None,
signed_url_key_id: None,
signed_url_secret: None,
allow_insecure_url_sources: false,
})
);
}
#[test]
fn parse_args_supports_serve_runtime_options() {
let command = parse_args(vec![
"truss".to_string(),
"serve".to_string(),
"--storage-root".to_string(),
"fixtures".to_string(),
"--public-base-url".to_string(),
"https://assets.example.com".to_string(),
"--signed-url-key-id".to_string(),
"public-dev".to_string(),
"--signed-url-secret".to_string(),
"secret-value".to_string(),
"--allow-insecure-url-sources".to_string(),
])
.expect("parse serve runtime options");
assert_eq!(
command,
Command::Serve(ServeCommand {
bind_addr: None,
storage_root: Some(PathBuf::from("fixtures")),
public_base_url: Some("https://assets.example.com".to_string()),
signed_url_key_id: Some("public-dev".to_string()),
signed_url_secret: Some("secret-value".to_string()),
allow_insecure_url_sources: true,
})
);
}
#[test]
fn parse_args_rejects_partial_signed_url_credentials() {
let error = parse_args(vec![
"truss".to_string(),
"serve".to_string(),
"--signed-url-key-id".to_string(),
"public-dev".to_string(),
])
.expect("parse serve args first");
let error = match error {
Command::Serve(command) => {
resolve_server_config(command).expect_err("partial credentials should fail")
}
_ => panic!("expected serve command"),
};
assert_eq!(error.exit_code, 1);
assert!(
error
.message
.contains("--signed-url-key-id and --signed-url-secret must be provided together")
);
}
#[test]
fn parse_args_rejects_invalid_public_base_url() {
let error = parse_args(vec![
"truss".to_string(),
"serve".to_string(),
"--public-base-url".to_string(),
"ftp://assets.example.com".to_string(),
])
.expect_err("invalid public base URL should fail");
assert_eq!(error.exit_code, 1);
assert!(
error
.message
.contains("requires an http:// or https:// URL"),
"message: {}",
error.message,
);
}
#[test]
#[serial]
fn resolve_server_config_applies_serve_overrides() {
let storage_root = temp_dir("serve-config");
let expected_storage_root = storage_root.canonicalize().expect("canonicalize temp dir");
let config = resolve_server_config(ServeCommand {
bind_addr: Some("127.0.0.1:0".to_string()),
storage_root: Some(storage_root.clone()),
public_base_url: Some("https://assets.example.com".to_string()),
signed_url_key_id: Some("public-dev".to_string()),
signed_url_secret: Some("secret-value".to_string()),
allow_insecure_url_sources: true,
})
.expect("resolve server config");
let _ = fs::remove_dir_all(storage_root);
assert_eq!(config.storage_root, expected_storage_root);
assert_eq!(
config.public_base_url.as_deref(),
Some("https://assets.example.com")
);
assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
assert_eq!(config.signed_url_secret.as_deref(), Some("secret-value"));
assert!(config.allow_insecure_url_sources);
}
#[test]
fn parse_args_supports_inspect_path() {
let command = parse_args(vec![
"truss".to_string(),
"inspect".to_string(),
"input.png".to_string(),
])
.expect("parse inspect path");
assert_eq!(
command,
Command::Inspect(super::InspectCommand {
input: InputSource::Path(PathBuf::from("input.png"))
})
);
}
#[test]
fn parse_args_supports_inspect_url() {
let command = parse_args(vec![
"truss".to_string(),
"inspect".to_string(),
"--url".to_string(),
"http://example.com/image.png".to_string(),
])
.expect("parse inspect url");
assert_eq!(
command,
Command::Inspect(super::InspectCommand {
input: InputSource::Url("http://example.com/image.png".to_string())
})
);
}
#[test]
fn parse_args_supports_convert_path_and_output() {
let command = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"input.png".to_string(),
"-o".to_string(),
"output.jpg".to_string(),
"--width".to_string(),
"100".to_string(),
"--fit".to_string(),
"contain".to_string(),
])
.expect("parse convert");
assert_eq!(
command,
Command::Convert(ConvertCommand {
input: InputSource::Path(PathBuf::from("input.png")),
output: OutputTarget::Path(PathBuf::from("output.jpg")),
options: TransformOptions {
width: Some(100),
fit: Some(Fit::Contain),
..TransformOptions::default()
},
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn parse_args_supports_optimize_subcommand() {
let command = parse_args(vec![
"truss".to_string(),
"optimize".to_string(),
"input.png".to_string(),
"-o".to_string(),
"output.png".to_string(),
"--mode".to_string(),
"lossless".to_string(),
])
.expect("parse optimize");
assert_eq!(
command,
Command::Optimize(ConvertCommand {
input: InputSource::Path(PathBuf::from("input.png")),
output: OutputTarget::Path(PathBuf::from("output.png")),
options: TransformOptions {
optimize: OptimizeMode::Lossless,
..TransformOptions::default()
},
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn parse_args_optimize_defaults_to_auto_mode() {
let command = parse_args(vec![
"truss".to_string(),
"optimize".to_string(),
"input.png".to_string(),
"-o".to_string(),
"output.webp".to_string(),
])
.expect("parse optimize");
assert_eq!(
command,
Command::Optimize(ConvertCommand {
input: InputSource::Path(PathBuf::from("input.png")),
output: OutputTarget::Path(PathBuf::from("output.webp")),
options: TransformOptions {
optimize: OptimizeMode::Auto,
..TransformOptions::default()
},
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn parse_args_rejects_non_optimizable_optimize_format() {
let error = parse_args(vec![
"truss".to_string(),
"optimize".to_string(),
"input.png".to_string(),
"-o".to_string(),
"output.svg".to_string(),
"--format".to_string(),
"svg".to_string(),
])
.expect_err("svg optimize output should be rejected");
assert!(
error
.message
.contains("optimization is not supported for svg output")
);
}
#[test]
fn parse_args_supports_convert_url_and_output() {
let command = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"--url".to_string(),
"http://example.com/image.png".to_string(),
"-o".to_string(),
"output.jpg".to_string(),
])
.expect("parse convert url");
assert_eq!(
command,
Command::Convert(ConvertCommand {
input: InputSource::Url("http://example.com/image.png".to_string()),
output: OutputTarget::Path(PathBuf::from("output.jpg")),
options: TransformOptions::default(),
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn parse_args_supports_sign_for_path_sources() {
let command = parse_args(vec![
"truss".to_string(),
"sign".to_string(),
"--base-url".to_string(),
"https://cdn.example.com".to_string(),
"--path".to_string(),
"/image.png".to_string(),
"--key-id".to_string(),
"public-dev".to_string(),
"--secret".to_string(),
"secret-value".to_string(),
"--expires".to_string(),
"4102444800".to_string(),
"--format".to_string(),
"jpeg".to_string(),
])
.expect("parse sign path");
assert_eq!(
command,
Command::Sign(SignCommand {
base_url: "https://cdn.example.com".to_string(),
source: SignedUrlSource::Path {
path: "/image.png".to_string(),
version: None
},
key_id: "public-dev".to_string(),
secret: "secret-value".to_string(),
expires: 4_102_444_800,
options: TransformOptions {
format: Some(MediaType::Jpeg),
..TransformOptions::default()
},
watermark_url: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
preset: None,
})
);
}
#[test]
fn parse_args_supports_sign_for_url_sources() {
let command = parse_args(vec![
"truss".to_string(),
"sign".to_string(),
"--base-url".to_string(),
"https://cdn.example.com".to_string(),
"--url".to_string(),
"https://origin.example.com/image.png".to_string(),
"--version".to_string(),
"v2".to_string(),
"--key-id".to_string(),
"public-dev".to_string(),
"--secret".to_string(),
"secret-value".to_string(),
"--expires".to_string(),
"4102444800".to_string(),
"--width".to_string(),
"120".to_string(),
])
.expect("parse sign url");
assert_eq!(
command,
Command::Sign(SignCommand {
base_url: "https://cdn.example.com".to_string(),
source: SignedUrlSource::Url {
url: "https://origin.example.com/image.png".to_string(),
version: Some("v2".to_string())
},
key_id: "public-dev".to_string(),
secret: "secret-value".to_string(),
expires: 4_102_444_800,
options: TransformOptions {
width: Some(120),
..TransformOptions::default()
},
watermark_url: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
preset: None,
})
);
}
#[test]
fn parse_args_supports_sign_with_preset() {
let command = parse_args(vec![
"truss".to_string(),
"sign".to_string(),
"--base-url".to_string(),
"https://cdn.example.com".to_string(),
"--path".to_string(),
"/hero.jpg".to_string(),
"--key-id".to_string(),
"mykey".to_string(),
"--secret".to_string(),
"s3cret".to_string(),
"--expires".to_string(),
"1700000000".to_string(),
"--preset".to_string(),
"thumbnail".to_string(),
])
.expect("parse sign with preset");
match command {
Command::Sign(s) => assert_eq!(s.preset.as_deref(), Some("thumbnail")),
_ => panic!("expected Sign command"),
}
}
#[test]
fn parse_args_rejects_missing_convert_output() {
let error = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"input.png".to_string(),
])
.expect_err("missing output should fail");
assert_eq!(error.exit_code, 1);
assert!(error.message.contains("requires -o"));
}
#[test]
fn parse_args_supports_inspect_https_url() {
let command = parse_args(vec![
"truss".to_string(),
"inspect".to_string(),
"--url".to_string(),
"https://example.com/image.png".to_string(),
])
.expect("inspect https url should parse");
assert_eq!(
command,
Command::Inspect(super::InspectCommand {
input: InputSource::Url("https://example.com/image.png".to_string())
})
);
}
#[test]
fn parse_args_rejects_invalid_convert_url_scheme() {
let error = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"--url".to_string(),
"ftp://example.com/image.png".to_string(),
"-o".to_string(),
"out.png".to_string(),
])
.expect_err("convert invalid scheme should fail");
assert_eq!(error.exit_code, 1);
assert!(
error
.message
.contains("requires an http:// or https:// URL")
);
}
#[test]
fn run_with_io_converts_without_explicit_subcommand() {
let input_path = temp_file_path("implicit-convert-input");
let output_path = temp_file_path("implicit-convert-output").with_extension("jpg");
fs::write(&input_path, png_bytes()).expect("write input file");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
input_path.display().to_string(),
"-o".to_string(),
output_path.display().to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
let output_bytes = fs::read(&output_path).expect("read output file");
let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
let _ = fs::remove_file(&input_path);
let _ = fs::remove_file(&output_path);
assert_eq!(exit_code, 0);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert_eq!(artifact.media_type, MediaType::Jpeg);
}
#[test]
fn run_with_io_inspects_a_file() {
let path = temp_file_path("inspect");
fs::write(&path, png_bytes()).expect("write temp file");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"inspect".to_string(),
path.display().to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
let _ = fs::remove_file(&path);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains("\"format\": \"png\""));
assert!(output.contains("\"mime\": \"image/png\""));
assert!(output.contains("\"width\": 4"));
assert!(output.contains("\"height\": 3"));
assert!(output.contains("\"hasAlpha\": true"));
assert!(output.contains("\"isAnimated\": false"));
}
#[test]
fn run_with_io_inspects_a_url() {
let (url, handle) = spawn_http_server(png_bytes(), "image/png");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"inspect".to_string(),
"--url".to_string(),
url,
],
&mut stdin,
&mut stdout,
&mut stderr,
);
handle.join().expect("join server thread");
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
assert!(
String::from_utf8(stdout)
.expect("utf8 stdout")
.contains("\"format\": \"png\"")
);
}
#[test]
fn run_with_io_converts_a_file_and_infers_output_format_from_extension() {
let input_path = temp_file_path("convert-input");
let output_path = temp_file_path("convert-output").with_extension("jpg");
fs::write(&input_path, png_bytes()).expect("write input file");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
input_path.display().to_string(),
"-o".to_string(),
output_path.display().to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
let output_bytes = fs::read(&output_path).expect("read output file");
let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
let _ = fs::remove_file(&input_path);
let _ = fs::remove_file(&output_path);
assert_eq!(exit_code, 0);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert_eq!(artifact.media_type, MediaType::Jpeg);
}
#[test]
fn run_with_io_optimizes_a_png_file() {
let input_path = temp_file_path("optimize-input");
let output_path = temp_file_path("optimize-output").with_extension("png");
fs::write(&input_path, png_bytes()).expect("write input file");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"optimize".to_string(),
input_path.display().to_string(),
"-o".to_string(),
output_path.display().to_string(),
"--mode".to_string(),
"lossless".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
let output_bytes = fs::read(&output_path).expect("read output file");
let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
let _ = fs::remove_file(&input_path);
let _ = fs::remove_file(&output_path);
assert_eq!(exit_code, 0);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert_eq!(artifact.media_type, MediaType::Png);
}
#[test]
fn run_with_io_converts_stdin_to_stdout() {
let mut stdin = Cursor::new(png_bytes());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"-".to_string(),
"-o".to_string(),
"-".to_string(),
"--format".to_string(),
"png".to_string(),
"--width".to_string(),
"8".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(
exit_code,
0,
"stderr was: {}",
String::from_utf8_lossy(&stderr)
);
assert!(stderr.is_empty());
let artifact = sniff_artifact(RawArtifact::new(stdout, None)).expect("sniff stdout output");
assert_eq!(artifact.media_type, MediaType::Png);
assert_eq!(artifact.metadata.width, Some(8));
}
#[test]
fn run_with_io_converts_a_url_to_a_file() {
let (url, handle) = spawn_http_server(png_bytes(), "image/png");
let output_path = temp_file_path("convert-url-output").with_extension("png");
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"--url".to_string(),
url,
"-o".to_string(),
output_path.display().to_string(),
"--width".to_string(),
"8".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
handle.join().expect("join server thread");
let output_bytes = fs::read(&output_path).expect("read output file");
let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
let _ = fs::remove_file(&output_path);
assert_eq!(exit_code, 0);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert_eq!(artifact.media_type, MediaType::Png);
assert_eq!(artifact.metadata.width, Some(8));
}
#[test]
fn run_with_io_reports_input_errors() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"inspect".to_string(),
"missing-file.png".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 2);
assert!(stdout.is_empty());
assert!(
String::from_utf8(stderr)
.expect("utf8 stderr")
.contains("failed to read missing-file.png")
);
}
#[test]
fn run_with_io_reports_decode_errors() {
let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 3);
assert!(stdout.is_empty());
assert!(
String::from_utf8(stderr)
.expect("utf8 stderr")
.contains("unknown file signature")
);
}
#[test]
fn double_dash_input_with_options_before() {
let result = parse_args(vec![
"truss".to_string(),
"convert".to_string(),
"-o".to_string(),
"out.jpg".to_string(),
"--width".to_string(),
"100".to_string(),
"--".to_string(),
"--leading-dash.png".to_string(),
]);
assert_eq!(
result.unwrap(),
Command::Convert(ConvertCommand {
input: InputSource::Path(PathBuf::from("--leading-dash.png")),
output: OutputTarget::Path(PathBuf::from("out.jpg")),
options: TransformOptions {
width: Some(100),
..TransformOptions::default()
},
watermark_path: None,
watermark_position: None,
watermark_opacity: None,
watermark_margin: None,
})
);
}
#[test]
fn inspect_double_dash_allows_leading_dash() {
let result = parse_args(vec![
"truss".to_string(),
"inspect".to_string(),
"--".to_string(),
"-weird-name.png".to_string(),
]);
assert_eq!(
result.unwrap(),
Command::Inspect(super::InspectCommand {
input: InputSource::Path(PathBuf::from("-weird-name.png"))
})
);
}
#[test]
fn completions_bash_produces_output() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"completions".to_string(),
"bash".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(
output.contains("truss"),
"bash completions should mention truss"
);
}
#[test]
fn completions_zsh_produces_output() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"completions".to_string(),
"zsh".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(!stdout.is_empty());
}
#[test]
fn completions_fish_produces_output() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec![
"truss".to_string(),
"completions".to_string(),
"fish".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
assert!(!stdout.is_empty());
}
#[test]
fn dash_dash_version_prints_version() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "--version".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(
output.starts_with("truss "),
"version output should start with 'truss ': {output}"
);
assert!(
output.contains(env!("CARGO_PKG_VERSION")),
"should contain package version: {output}"
);
}
#[test]
fn dash_v_prints_version() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "-V".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(output.contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn help_includes_version_and_sponsor() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let exit_code = run_with_io(
vec!["truss".to_string(), "--help".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(exit_code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(
output.contains(env!("CARGO_PKG_VERSION")),
"help should include version: {output}"
);
assert!(
output.contains("Sponsor:"),
"help should include sponsor link: {output}"
);
assert!(
output.contains("github.com/sponsors/nao1215"),
"help should include GitHub Sponsors URL: {output}"
);
}
#[test]
fn preprocess_args_extensionless_file_is_implicit_convert() {
let dir = temp_dir("extensionless");
let file_path = dir.join("image");
fs::write(&file_path, png_bytes()).expect("write extensionless fixture");
let original_dir = std::env::current_dir().expect("get cwd");
std::env::set_current_dir(&dir).expect("set cwd to temp dir");
let args = vec![
"truss".to_string(),
"image".to_string(),
"-o".to_string(),
"out.jpg".to_string(),
];
let result = preprocess_args(args);
std::env::set_current_dir(&original_dir).expect("restore cwd");
assert_eq!(
result[1], "convert",
"extensionless file should trigger implicit convert"
);
assert_eq!(result[2], "image", "bare file name should follow convert");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn preprocess_args_nonexistent_extensionless_is_unknown_subcommand() {
let args = vec![
"truss".to_string(),
"nonexistent_subcommand_xyz".to_string(),
];
let result = preprocess_args(args.clone());
assert_eq!(
result, args,
"non-existent extensionless name should pass through unchanged"
);
}
#[test]
fn exit_code_invalid_options_is_usage_error() {
let png_bytes = {
let mut img = image::RgbaImage::new(1, 1);
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
1,
1,
image::ColorType::Rgba8.into(),
)
.unwrap();
buf
};
let mut stdin = Cursor::new(png_bytes);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec![
"truss".to_string(),
"convert".to_string(),
"-".to_string(),
"-o".to_string(),
"-".to_string(),
"--format".to_string(),
"jpeg".to_string(),
"--quality".to_string(),
"0".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 1, "InvalidOptions should exit with code 1 (usage)");
}
#[test]
fn help_completions_shows_completions_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"completions".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
}
#[test]
fn help_version_shows_version_help() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"version".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Version));
}
#[test]
fn completions_dash_help_shows_completions_help() {
let result = parse_args(vec![
"truss".to_string(),
"completions".to_string(),
"--help".to_string(),
]);
assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
}
#[test]
fn completions_without_shell_exits_with_usage_error() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec!["truss".to_string(), "completions".to_string()],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 1, "completions without shell arg should exit 1");
}
#[test]
fn completions_bash_includes_implicit_args() {
let mut stdin = Cursor::new(Vec::<u8>::new());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with_io(
vec![
"truss".to_string(),
"completions".to_string(),
"bash".to_string(),
],
&mut stdin,
&mut stdout,
&mut stderr,
);
assert_eq!(code, 0);
let output = String::from_utf8(stdout).expect("utf8 stdout");
assert!(
output.contains("--output"),
"bash completions should include --output for implicit convert"
);
assert!(
output.contains("--bind"),
"bash completions should include --bind for implicit serve"
);
}
#[test]
fn help_exit_codes_includes_runtime() {
let text = super::help_top_level();
assert!(
text.contains("5 Runtime error"),
"help text should document exit code 5"
);
}
#[test]
fn unknown_help_topic_hint_lists_all_topics() {
let result = parse_args(vec![
"truss".to_string(),
"help".to_string(),
"nonexistent".to_string(),
]);
let err = result.unwrap_err();
let hint = err.hint.unwrap();
assert!(hint.contains("completions"), "hint should list completions");
assert!(hint.contains("version"), "hint should list version");
}
}