Skip to main content

truss/adapters/cli/
mod.rs

1use crate::adapters::server::SignedUrlSource;
2use crate::{
3    CropRegion, Fit, MediaType, OptimizeMode, Position, Rgba8, Rotation, TargetQuality,
4    TransformOptions,
5};
6use clap::{CommandFactory, Parser, Subcommand};
7use std::ffi::{OsStr, OsString};
8use std::fs;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::ExitCode;
12
13use crate::core::error_class::ErrorClass;
14use std::str::FromStr;
15
16mod convert;
17mod inspect;
18mod serve;
19mod sign;
20
21/// The size cap for a source fetched over HTTP.
22///
23/// The default of `TRUSS_MAX_SOURCE_BYTES`, so a URL the server will fetch is one this
24/// adapter can be pointed at. Trying a request locally before deploying it is what the
25/// command line is for, and a cap of its own turned a source the server serves into one it
26/// refuses to look at.
27const MAX_REMOTE_BYTES: u64 = crate::adapters::server::remote::MAX_SOURCE_BYTES;
28
29/// The size cap for a watermark fetched over HTTP.
30///
31/// The server keeps this apart from the source limit and publishes it as
32/// `TRUSS_MAX_WATERMARK_BYTES`: an overlay has no business being the size of a source image,
33/// and a watermark the server would refuse is one there is no point in accepting here.
34pub(super) const MAX_REMOTE_WATERMARK_BYTES: u64 =
35    crate::adapters::server::remote::MAX_WATERMARK_BYTES;
36
37// ---------------------------------------------------------------------------
38// Exit codes — kept in sync with help text
39// ---------------------------------------------------------------------------
40
41/// Successful completion.
42const EXIT_SUCCESS: u8 = 0;
43/// Usage error (bad arguments, missing required flags).
44const EXIT_USAGE: u8 = 1;
45/// I/O error (file not found, permission denied, network failure).
46const EXIT_IO: u8 = 2;
47/// Input error (unsupported format, corrupt file).
48const EXIT_INPUT: u8 = 3;
49/// Transform error (encode failure, size limit exceeded, deadline).
50const EXIT_TRANSFORM: u8 = 4;
51/// Runtime error (bind failure, stdout write failure).
52const EXIT_RUNTIME: u8 = 5;
53
54// ---------------------------------------------------------------------------
55// Help text — split by topic (hand-crafted for rich output)
56// ---------------------------------------------------------------------------
57
58fn help_top_level() -> String {
59    format!(
60        "\
61truss {version} - an image transformation tool and server
62
63Converts, resizes, and re-encodes images (JPEG, PNG, WebP, AVIF, BMP, TIFF, SVG).
64Can also run as an HTTP image-transform server.
65
66USAGE:
67  truss <COMMAND> [OPTIONS]
68  truss <INPUT> -o <OUTPUT> [OPTIONS]   (implicit convert)
69  truss --bind <ADDR> [OPTIONS]         (implicit serve)
70
71COMMANDS:
72  convert       Convert and transform an image file
73  optimize      Optimize an image for smaller output size
74  inspect       Show metadata (format, dimensions, alpha) of an image
75  serve         Start the HTTP image-transform server
76  validate      Check server configuration without starting the server
77  sign          Generate a signed public URL for the server
78  completions   Generate shell completion scripts
79  help          Show help for a command (e.g. truss help convert)
80
81OPTIONS:
82  -V, --version   Print version information
83
84EXAMPLES:
85  truss photo.png -o photo.jpg --width 800
86  truss inspect photo.jpg
87  truss serve --bind 0.0.0.0:8080 --storage-root /var/images
88  truss sign --base-url https://cdn.example.com --path /hero.jpg \\
89    --key-id mykey --secret s3cret --expires 1700000000
90  truss completions bash > ~/.local/share/bash-completion/completions/truss
91
92Run 'truss help <command>' for more information on a specific command.
93
94EXIT CODES:
95  0  Success
96  1  Usage error (bad arguments)
97  2  I/O error (file not found, permission denied, network failure)
98  3  Input error (unsupported format, corrupt file)
99  4  Transform error (encode failure, size limit exceeded, deadline)
100  5  Runtime error (bind failure, stdout write failure)
101
102Sponsor: https://github.com/sponsors/nao1215
103",
104        version = env!("CARGO_PKG_VERSION"),
105    )
106}
107
108const HELP_CONVERT: &str = "\
109truss convert - convert and transform an image file
110
111USAGE:
112  truss convert <INPUT> -o <OUTPUT> [OPTIONS]
113  truss convert --url <URL> -o <OUTPUT> [OPTIONS]
114  truss convert - -o - --format jpeg    (stdin to stdout)
115
116  The 'convert' subcommand can be omitted:
117    truss <INPUT> -o <OUTPUT> [OPTIONS]
118    truss --url <URL> -o <OUTPUT> [OPTIONS]
119
120  For a file whose name starts with -, put the options first and end them with --,
121  or write the path with a ./ prefix. An output cannot be escaped with --, because
122  -o takes the next argument whatever it looks like; assign it with = instead:
123    truss convert -o out.jpg -- -input.png
124    truss convert input.png --output=-output.jpg
125
126OPTIONS:
127  -o, --output <OUTPUT>    Output file path, or - for stdout (required)
128      --url <URL>          Fetch input from an HTTP(S) URL
129      --width <PX>         Target width in pixels
130      --height <PX>        Target height in pixels
131      --fit <MODE>         How to fit into target dimensions (requires --width and --height)
132                           contain: scale to fit, then pad to the exact box (default)
133                           cover:   scale to fill the box, cropping the excess
134                           fill:    stretch each axis to the box, ignoring aspect ratio
135                           inside:  scale to fit, no padding; the output is at most the
136                                    box and usually smaller on one axis
137      --position <POS>     Crop anchor for cover mode (default: center)
138                           center, top, right, bottom, left,
139                           top-left, top-right, bottom-left, bottom-right
140      --format <FMT>       Output format: jpeg, png, webp, avif, bmp, tiff, svg
141                           (default: inferred from output extension)
142      --quality <1-100>    Encoding quality for lossy formats
143      --optimize <MODE>    Optimization mode: none, auto, lossless, lossy
144      --target-quality <TARGET>
145                           Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
146      --background <COLOR> Background color as RRGGBB or RRGGBBAA hex
147      --rotate <DEG>       Rotate clockwise by whole degrees. Negative turns counter-clockwise,
148                           and angles past a full turn wrap, so -90 and 270 are the same.
149                           A multiple of 90 is exact; any other angle resamples and grows
150                           the canvas to the rotated bounding box, filling the exposed
151                           corners with --background (transparent, or white for formats
152                           without alpha)
153      --auto-orient        Apply EXIF orientation and reset tag (default)
154      --no-auto-orient     Skip EXIF orientation correction (add --keep-metadata to keep
155                           the tag; stripping it as well leaves the image rotated)
156      --strip-metadata     Remove all metadata (default; lossy optimization keeps the
157                           ICC profile so colors are not shifted by the re-encode)
158      --keep-metadata      Preserve EXIF, ICC, and other supported metadata
159      --preserve-exif      Preserve EXIF only (strip ICC and others)
160      --crop <x,y,w,h>     Explicit crop region as x,y,width,height (applied before resize)
161      --blur <SIGMA>       Gaussian blur sigma (0.1-100.0)
162      --sharpen <SIGMA>    Sharpen sigma (0.1-100.0)
163      --grayscale          Desaturate the image to grayscale (applied after resize, blur,
164                           and sharpen, and before the watermark)
165      --without-enlargement
166                           Never scale an image up. A source already within the requested
167                           size keeps that size. Combines with any --fit; contain still
168                           pads out to the full box, and cover returns the box intersected
169                           with the source rather than the whole box
170      --watermark <FILE|URL>
171                           Watermark image to composite onto the output, from a file or an
172                           HTTP(S) URL of at most 10 MB. The overlay itself must be a raster
173                           image; the picture it goes onto may be an SVG
174      --watermark-position <POS>  Watermark placement (default: bottom-right)
175                           center, top, right, bottom, left,
176                           top-left, top-right, bottom-left, bottom-right
177      --watermark-opacity <1-100> Watermark opacity percentage (default: 50)
178      --watermark-margin <PX>     Margin from edge in pixels (default: 10)
179
180EXAMPLES:
181  truss photo.png -o photo.jpg --width 800
182  truss --url https://example.com/img.png -o out.webp --format webp --quality 75
183  cat photo.png | truss convert - -o - --format jpeg > photo.jpg
184  truss photo.png -o thumb.png --width 200 --height 200 --fit cover
185  truss diagram.svg -o safe.svg
186  truss diagram.svg -o diagram.png --width 1024
187
188SVG:
189  An SVG output sanitizes the document and returns it as written, so an option that asks
190  for a different picture is refused rather than ignored. Convert to a raster format to
191  resize, rotate, or recolour a drawing; --fit, --position, and --without-enlargement then
192  mean what they mean for any other input.
193
194ORDER:
195  Options are applied in a fixed order, whatever order they are written in:
196  auto-orient, rotate, crop, resize, blur, sharpen, grayscale, watermark, encode.
197  https://github.com/nao1215/truss/blob/main/docs/pipeline.md
198";
199
200const HELP_OPTIMIZE: &str = "\
201truss optimize - reduce image file size with format-aware optimization
202
203USAGE:
204  truss optimize <INPUT> -o <OUTPUT> [OPTIONS]
205  truss optimize --url <URL> -o <OUTPUT> [OPTIONS]
206  truss optimize - -o - --format webp
207
208OPTIONS:
209  -o, --output <OUTPUT>    Output file path, or - for stdout (required)
210      --url <URL>          Fetch input from an HTTP(S) URL
211      --format <FMT>       Output format: jpeg, png, webp, avif
212                           (default: inferred from output extension or input format)
213      --mode <MODE>        Optimization mode: auto (default), lossless, lossy
214                           For a plain re-encode with no optimization, use truss convert
215                           lossless cannot rotate pixels, so a JPEG carrying an EXIF
216                           orientation needs --keep-metadata or --preserve-exif
217      --quality <1-100>    Optional quality cap for lossy optimization
218      --target-quality <TARGET>
219                           Perceptual target for lossy optimization (e.g. ssim:0.98, psnr:42)
220      --auto-orient        Apply EXIF orientation and reset tag (default)
221      --no-auto-orient     Skip EXIF orientation correction (add --keep-metadata to keep
222                           the tag; stripping it as well leaves the image rotated)
223      --strip-metadata     Remove all metadata (default; lossy optimization keeps the
224                           ICC profile so colors are not shifted by the re-encode)
225      --keep-metadata      Preserve EXIF, ICC, and other supported metadata
226      --preserve-exif      Preserve EXIF only (strip ICC and others)
227
228EXAMPLES:
229  truss optimize photo.jpg -o out.jpg
230  truss optimize photo.jpg -o out.jpg --mode lossy --target-quality ssim:0.98
231  truss optimize graphic.png -o out.png --mode lossless
232";
233
234const HELP_INSPECT: &str = "\
235truss inspect - show metadata of an image
236
237USAGE:
238  truss inspect <FILE>
239  truss inspect --url <URL>
240  truss inspect -               (read from stdin)
241
242  Use -- to separate options from file paths starting with -:
243    truss inspect -- -weird-name.png
244
245OUTPUT:
246  Prints JSON with format, MIME type, dimensions, alpha, and animation info.
247
248  width/height are the dimensions as stored in the file. orientedWidth/orientedHeight
249  are what 'truss convert' produces: an EXIF orientation of 5 to 8 transposes them, and
250  orientation reports the tag when the file carries one.
251
252EXAMPLES:
253  truss inspect photo.jpg
254  truss inspect --url https://example.com/photo.jpg
255  cat photo.png | truss inspect -
256";
257
258fn help_serve() -> String {
259    let mut s = String::from(
260        "\
261truss serve - start the HTTP image-transform server
262
263USAGE:
264  truss serve [OPTIONS]
265
266  Server flags can also be used at the top level:
267    truss --bind 0.0.0.0:8080 --storage-root /var/images
268
269OPTIONS:
270      --bind <ADDR>                   Listen address (default: 127.0.0.1:8080)
271      --storage-root <PATH>           Root directory for path-based sources
272      --public-base-url <URL>         External base URL for signed URLs
273      --signed-url-key-id <KEY_ID>    Key identifier for signed public URLs
274      --signed-url-secret <SECRET>    Shared secret for HMAC verification
275      --allow-insecure-url-sources    Allow private-network URLs (dev/test only)
276
277ENVIRONMENT VARIABLES:
278  Defaults, ranges and the full description of each setting are in docs/configuration.md.
279
280  Core:
281  TRUSS_BIND_ADDR                     Listen address override
282  TRUSS_STORAGE_ROOT                  Storage root override (default: working directory)
283  TRUSS_BEARER_TOKEN                  Private API authentication token
284",
285    );
286
287    // The backends this build can resolve a public by-path source from, which is the one
288    // row whose accepted values depend on the features it was compiled with.
289    {
290        use std::fmt::Write as FmtWrite;
291
292        #[allow(unused_mut, clippy::useless_vec)]
293        let mut backends = vec!["filesystem (default)"];
294        #[cfg(feature = "s3")]
295        backends.push("s3");
296        #[cfg(feature = "gcs")]
297        backends.push("gcs");
298        #[cfg(feature = "azure")]
299        backends.push("azure");
300        let _ = writeln!(
301            s,
302            "  TRUSS_STORAGE_BACKEND               Source for public by-path resolution: {}",
303            backends.join(", ")
304        );
305    }
306
307    s.push_str(
308        "  TRUSS_MAX_CONCURRENT_TRANSFORMS     Concurrent transforms before a request gets 503
309  TRUSS_TRANSFORM_DEADLINE_SECS       Per-transform deadline in seconds
310  TRUSS_MAX_INPUT_PIXELS              Input pixels accepted before decode
311  TRUSS_MAX_UPLOAD_BYTES              Upload body size accepted
312  TRUSS_MAX_SOURCE_BYTES              Source image size accepted from disk or a URL
313  TRUSS_MAX_WATERMARK_BYTES           Watermark image size accepted from a URL
314  TRUSS_MAX_REMOTE_REDIRECTS          Redirects followed when fetching a remote URL
315  TRUSS_STORAGE_TIMEOUT_SECS          Download timeout for object storage backends
316  TRUSS_KEEP_ALIVE_MAX_REQUESTS       Requests per keep-alive connection
317  TRUSS_SHUTDOWN_DRAIN_SECS           Drain period during graceful shutdown
318  TRUSS_RESPONSE_HEADERS              Custom image-response headers as JSON
319  TRUSS_DISABLE_COMPRESSION           Turn off gzip for non-image responses
320  TRUSS_COMPRESSION_LEVEL             Gzip level for non-image responses
321  TRUSS_LOG_LEVEL                     Log verbosity: error, warn, info, debug
322
323  Health and load:
324  TRUSS_HEALTH_TOKEN                  Bearer token for GET /health
325  TRUSS_HEALTH_CACHE_MIN_FREE_BYTES   Free bytes on the cache disk /health/ready needs
326  TRUSS_HEALTH_MAX_MEMORY_BYTES       Process RSS /health/ready allows (Linux)
327  TRUSS_HEALTH_HYSTERESIS_MARGIN      Recovery margin for the readiness thresholds
328  TRUSS_HEALTH_CACHE_TTL_SECS         How long /health/ready reuses a measurement
329  TRUSS_RATE_LIMIT_RPS                Sustained per-client requests per second
330  TRUSS_RATE_LIMIT_BURST              Requests a client may send back to back
331  TRUSS_TRUSTED_PROXIES               IPs or CIDRs whose forwarded-for headers are trusted
332
333  Metrics:
334  TRUSS_METRICS_TOKEN                 Bearer token for GET /metrics
335  TRUSS_DISABLE_METRICS               Turn the /metrics endpoint off entirely
336
337  Signed URLs, caching and presets:
338  TRUSS_PUBLIC_BASE_URL               Public base URL override
339  TRUSS_SIGNING_KEYS                  Multiple signing keys as JSON {\"keyId\":\"secret\",...}
340  TRUSS_SIGNED_URL_KEY_ID             Signing key identifier (single-key shorthand)
341  TRUSS_SIGNED_URL_SECRET             Signing shared secret (single-key shorthand)
342  TRUSS_CACHE_ROOT                    On-disk transform cache directory
343  TRUSS_CACHE_MAX_BYTES               Size budget for the cache directory
344  TRUSS_PUBLIC_MAX_AGE                Cache-Control max-age for public GET responses
345  TRUSS_PUBLIC_STALE_WHILE_REVALIDATE Cache-Control stale-while-revalidate for the same
346  TRUSS_FORMAT_PREFERENCE             Output formats ordered by server preference
347  TRUSS_DISABLE_ACCEPT_NEGOTIATION    Turn off Accept-based content negotiation
348  TRUSS_ALLOW_INSECURE_URL_SOURCES    Enable insecure URL sources
349  TRUSS_PRESETS                       Named transform presets as inline JSON
350  TRUSS_PRESETS_FILE                  Path to a JSON file containing named transform presets
351",
352    );
353
354    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
355    s.push_str("\n  Storage backend:\n");
356
357    #[cfg(feature = "s3")]
358    s.push_str(
359        "  TRUSS_S3_BUCKET                     Default S3 bucket name (required when backend=s3)
360  TRUSS_S3_FORCE_PATH_STYLE           Use path-style S3 addressing (set to 1/true/yes/on for MinIO, etc.)
361  AWS_ACCESS_KEY_ID                   AWS access key for S3 authentication
362  AWS_SECRET_ACCESS_KEY               AWS secret key for S3 authentication
363  AWS_REGION                          AWS region for the S3 client (e.g. us-east-1)
364  AWS_ENDPOINT_URL                    Custom S3-compatible endpoint URL (e.g. http://minio:9000)
365",
366    );
367
368    #[cfg(feature = "gcs")]
369    s.push_str(
370        "  TRUSS_GCS_BUCKET                    Default GCS bucket name (required when backend=gcs)
371  TRUSS_GCS_ENDPOINT                  Custom GCS endpoint URL (for testing with fake-gcs-server, etc.)
372  GOOGLE_APPLICATION_CREDENTIALS      Path to GCS service account JSON key file
373  GOOGLE_APPLICATION_CREDENTIALS_JSON Service account JSON key given inline
374",
375    );
376
377    #[cfg(feature = "azure")]
378    s.push_str(
379        "  TRUSS_AZURE_CONTAINER               Default Azure container name (required when backend=azure)
380  TRUSS_AZURE_ENDPOINT                Custom Azure Blob endpoint URL (for Azurite, etc.)
381  AZURE_STORAGE_ACCOUNT_NAME          Storage account name (derives endpoint when TRUSS_AZURE_ENDPOINT is unset)
382",
383    );
384
385    #[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
386    s.push_str(
387        "\
388\nNOTE: When using local emulators (MinIO, fake-gcs-server, Azurite), set
389  TRUSS_ALLOW_INSECURE_URL_SOURCES=true to allow plain-HTTP endpoints.
390",
391    );
392
393    s.push_str(
394        "\
395\nEXAMPLES:
396  truss serve --bind 0.0.0.0:8080 --storage-root /var/images
397  truss serve --bind 127.0.0.1:3000 --signed-url-key-id mykey --signed-url-secret s3cret
398",
399    );
400    s
401}
402
403const HELP_SIGN: &str = "\
404truss sign - generate a signed public URL
405
406USAGE:
407  truss sign --base-url <URL> --path <PATH> \\
408    --key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
409  truss sign --base-url <URL> --url <URL> \\
410    --key-id <KEY_ID> --secret <SECRET> --expires <UNIX_SECS> [OPTIONS]
411
412REQUIRED:
413      --base-url <URL>     CDN base URL for the signed request
414      --path <PATH>        Image path on the server (mutually exclusive with --url)
415      --url <URL>          Remote image URL to transform (mutually exclusive with --path)
416      --key-id <KEY_ID>    Signing key identifier
417      --secret <SECRET>    HMAC shared secret
418      --expires <UNIX_SECS> Expiration as Unix timestamp
419
420OPTIONAL:
421      --version <VALUE>    Cache-busting version tag
422      --method <METHOD>    HTTP method the URL is signed for: get or head (default: get).
423                           The signature covers the method, so a URL signed for one is
424                           refused for the other
425      --width, --height, --fit, --position, --format, --quality,
426      --optimize, --target-quality, --background, --rotate, --auto-orient, --no-auto-orient,
427      --strip-metadata, --keep-metadata, --preserve-exif, --crop, --blur, --sharpen,
428      --grayscale, --without-enlargement
429      --watermark-url <URL>          Watermark image URL to embed in the signed URL
430      --watermark-position <POS>     Watermark placement (default: bottom-right)
431      --watermark-opacity <1-100>    Watermark opacity (default: 50)
432      --watermark-margin <PX>        Watermark margin from edge in pixels (default: 10)
433      --preset <NAME>                Named transform preset (server-side)
434
435EXAMPLES:
436  truss sign --base-url https://cdn.example.com \\
437    --path /photos/hero.jpg --key-id mykey --secret s3cret \\
438    --expires 1700000000 --width 640 --format webp
439";
440
441const HELP_VALIDATE: &str = "\
442truss validate - check server configuration without starting the server
443
444USAGE:
445  truss validate
446
447Parses and validates all environment variables used by `truss serve`.
448Exits 0 when the configuration is valid, or exits 1 with a description
449of each error found.
450
451Useful in CI/CD pipelines to catch configuration mistakes early.
452";
453
454const HELP_COMPLETIONS: &str = "\
455truss completions - generate shell completion scripts
456
457USAGE:
458  truss completions <SHELL>
459
460SHELLS:
461  bash, zsh, fish, elvish, powershell
462
463EXAMPLES:
464  truss completions bash > ~/.local/share/bash-completion/completions/truss
465  truss completions zsh > ~/.zfunc/_truss
466  truss completions fish > ~/.config/fish/completions/truss.fish
467";
468
469const HELP_VERSION: &str = "\
470truss version - print version information
471
472USAGE:
473  truss version
474  truss -V
475  truss --version
476";
477
478// ---------------------------------------------------------------------------
479// Clap derive structs
480// ---------------------------------------------------------------------------
481
482#[derive(Parser)]
483#[command(
484    name = "truss",
485    about = "an image transformation tool and server",
486    disable_help_subcommand = true,
487    disable_help_flag = true,
488    disable_version_flag = true
489)]
490struct Cli {
491    #[command(subcommand)]
492    command: Option<CliSubcommand>,
493}
494
495#[derive(Subcommand)]
496enum CliSubcommand {
497    /// Convert and transform an image file
498    #[command(disable_help_flag = true)]
499    Convert(ClapConvertArgs),
500    /// Optimize an image for smaller output size
501    #[command(disable_help_flag = true)]
502    Optimize(ClapOptimizeArgs),
503    /// Show metadata (format, dimensions, alpha) of an image
504    #[command(disable_help_flag = true)]
505    Inspect(ClapInspectArgs),
506    /// Start the HTTP image-transform server
507    #[command(disable_help_flag = true)]
508    Serve(ClapServeArgs),
509    /// Generate a signed public URL for the server
510    #[command(disable_help_flag = true)]
511    Sign(ClapSignArgs),
512    /// Show help for a command
513    Help { topic: Option<String> },
514    /// Print version information
515    Version,
516    /// Validate server configuration without starting the server
517    #[command(disable_help_flag = true)]
518    Validate(ClapValidateArgs),
519    /// Generate shell completion scripts
520    #[command(disable_help_flag = true)]
521    Completions {
522        #[arg(value_enum)]
523        shell: Option<clap_complete::Shell>,
524        /// Print help
525        #[arg(long)]
526        help: bool,
527    },
528}
529
530#[derive(clap::Args)]
531struct ClapConvertArgs {
532    /// Input file path, or - for stdin
533    #[arg(allow_hyphen_values = true)]
534    input: Option<PathBuf>,
535    /// Output file path, or - for stdout
536    #[arg(short = 'o', long = "output", allow_hyphen_values = true)]
537    output: Option<PathBuf>,
538    /// Fetch input from an HTTP(S) URL
539    #[arg(long)]
540    url: Option<String>,
541    /// Target width in pixels
542    #[arg(long, value_parser = parse_width)]
543    width: Option<u32>,
544    /// Target height in pixels
545    #[arg(long, value_parser = parse_height)]
546    height: Option<u32>,
547    /// How to fit into target dimensions (contain, cover, fill, inside)
548    #[arg(long, value_parser = parse_fit)]
549    fit: Option<Fit>,
550    /// Crop anchor for cover mode
551    #[arg(long, value_parser = parse_position)]
552    position: Option<Position>,
553    /// Output format (jpeg, png, webp, avif, bmp, svg)
554    #[arg(long, value_parser = parse_media_type)]
555    format: Option<MediaType>,
556    /// Encoding quality for lossy formats (1-100)
557    #[arg(long, value_parser = parse_quality)]
558    quality: Option<u8>,
559    /// Optimization mode (none, auto, lossless, lossy)
560    #[arg(long, value_parser = parse_optimize_mode)]
561    optimize: Option<OptimizeMode>,
562    /// Perceptual target for lossy optimization
563    #[arg(long = "target-quality", value_parser = parse_target_quality)]
564    target_quality: Option<TargetQuality>,
565    /// Background color as RRGGBB or RRGGBBAA hex
566    #[arg(long, value_parser = parse_background)]
567    background: Option<Rgba8>,
568    /// Rotate clockwise by whole degrees; negative turns counter-clockwise
569    ///
570    /// `allow_hyphen_values` is what lets `--rotate -90` through: without it clap reads
571    /// the leading `-` as the start of another flag and rejects the value.
572    #[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
573    rotate: Option<Rotation>,
574    /// Apply EXIF orientation and reset tag
575    #[arg(long)]
576    auto_orient: bool,
577    /// Skip EXIF orientation correction
578    #[arg(long)]
579    no_auto_orient: bool,
580    /// Remove all metadata
581    #[arg(long)]
582    strip_metadata: bool,
583    /// Preserve EXIF, ICC, and other supported metadata
584    #[arg(long)]
585    keep_metadata: bool,
586    /// Preserve EXIF only (strip ICC and others)
587    #[arg(long)]
588    preserve_exif: bool,
589    /// Explicit crop region as x,y,width,height
590    ///
591    /// `allow_hyphen_values` is here for the same reason it is on `--rotate`: without it
592    /// clap reads a leading `-` as the start of another flag and answers with its own
593    /// message, so a negative origin never reaches `parse_crop` and the caller is told to
594    /// pass the value after `--`, which turns the next word into an input path.
595    #[arg(long, value_parser = parse_crop, allow_hyphen_values = true)]
596    crop: Option<CropRegion>,
597    /// Apply Gaussian blur (sigma: 0.1-100.0)
598    #[arg(long, value_parser = parse_blur)]
599    blur: Option<f32>,
600    /// Apply sharpen filter (sigma: 0.1-100.0)
601    #[arg(long, value_parser = parse_sharpen)]
602    sharpen: Option<f32>,
603    /// Desaturate the image to grayscale
604    #[arg(long)]
605    grayscale: bool,
606    /// Never scale an image up to reach the requested size
607    #[arg(long)]
608    without_enlargement: bool,
609    /// Watermark image file path
610    #[arg(long)]
611    watermark: Option<PathBuf>,
612    /// Watermark position (default: bottom-right)
613    #[arg(long, value_parser = parse_position)]
614    watermark_position: Option<Position>,
615    /// Watermark opacity 1-100 (default: 50)
616    #[arg(long, value_parser = parse_watermark_opacity)]
617    watermark_opacity: Option<u8>,
618    /// Watermark margin in pixels (default: 10)
619    #[arg(long, value_parser = parse_watermark_margin)]
620    watermark_margin: Option<u32>,
621    /// Show help for convert
622    #[arg(short = 'h', long = "help")]
623    help: bool,
624}
625
626#[derive(clap::Args)]
627struct ClapOptimizeArgs {
628    /// Input file path, or - for stdin
629    #[arg(allow_hyphen_values = true)]
630    input: Option<PathBuf>,
631    /// Output file path, or - for stdout
632    #[arg(short = 'o', long = "output", allow_hyphen_values = true)]
633    output: Option<PathBuf>,
634    /// Fetch input from an HTTP(S) URL
635    #[arg(long)]
636    url: Option<String>,
637    /// Output format (jpeg, png, webp, avif)
638    #[arg(long, value_parser = parse_optimizable_media_type)]
639    format: Option<MediaType>,
640    /// Quality cap for lossy optimization (1-100)
641    #[arg(long, value_parser = parse_quality)]
642    quality: Option<u8>,
643    /// Optimization mode (auto, lossless, lossy)
644    #[arg(long = "mode", value_parser = parse_optimizing_mode)]
645    mode: Option<OptimizeMode>,
646    /// Perceptual target for lossy optimization
647    #[arg(long = "target-quality", value_parser = parse_target_quality)]
648    target_quality: Option<TargetQuality>,
649    /// Apply EXIF orientation and reset tag
650    #[arg(long)]
651    auto_orient: bool,
652    /// Skip EXIF orientation correction
653    #[arg(long)]
654    no_auto_orient: bool,
655    /// Remove all metadata
656    #[arg(long)]
657    strip_metadata: bool,
658    /// Preserve EXIF, ICC, and other supported metadata
659    #[arg(long)]
660    keep_metadata: bool,
661    /// Preserve EXIF only (strip ICC and others)
662    #[arg(long)]
663    preserve_exif: bool,
664    /// Show help for optimize
665    #[arg(short = 'h', long = "help")]
666    help: bool,
667}
668
669#[derive(clap::Args)]
670struct ClapInspectArgs {
671    /// Input file path, or - for stdin
672    #[arg(allow_hyphen_values = true)]
673    input: Option<PathBuf>,
674    /// Fetch input from an HTTP(S) URL
675    #[arg(long)]
676    url: Option<String>,
677    /// Show help for inspect
678    #[arg(short = 'h', long = "help")]
679    help: bool,
680}
681
682#[derive(clap::Args)]
683struct ClapServeArgs {
684    /// Listen address (e.g. 0.0.0.0:8080)
685    #[arg(long)]
686    bind: Option<String>,
687    /// Root directory for path-based sources
688    #[arg(long)]
689    storage_root: Option<PathBuf>,
690    /// External base URL for signed URLs
691    #[arg(long, value_parser = parse_url_value)]
692    public_base_url: Option<String>,
693    /// Key identifier for signed public URLs
694    #[arg(long)]
695    signed_url_key_id: Option<String>,
696    /// Shared secret for HMAC verification
697    #[arg(long)]
698    signed_url_secret: Option<String>,
699    /// Allow private-network URLs (dev/test only)
700    #[arg(long)]
701    allow_insecure_url_sources: bool,
702    /// Show help for serve
703    #[arg(short = 'h', long = "help")]
704    help: bool,
705}
706
707#[derive(clap::Args)]
708struct ClapValidateArgs {
709    /// Show help for validate
710    #[arg(short = 'h', long = "help")]
711    help: bool,
712}
713
714#[derive(clap::Args)]
715struct ClapSignArgs {
716    /// CDN base URL for the signed request
717    #[arg(long, value_parser = parse_url_value)]
718    base_url: Option<String>,
719    /// Image path on the server
720    #[arg(long)]
721    path: Option<String>,
722    /// Remote image URL to transform
723    #[arg(long, value_parser = parse_url_value)]
724    url: Option<String>,
725    /// Cache-busting version tag
726    #[arg(long)]
727    version: Option<String>,
728    /// HTTP method the signed URL is valid for (get or head)
729    #[arg(long, value_parser = parse_signed_method)]
730    method: Option<SignedMethod>,
731    /// Signing key identifier
732    #[arg(long)]
733    key_id: Option<String>,
734    /// HMAC shared secret
735    #[arg(long)]
736    secret: Option<String>,
737    /// Expiration as Unix timestamp
738    #[arg(long)]
739    expires: Option<u64>,
740    /// Target width in pixels
741    #[arg(long, value_parser = parse_width)]
742    width: Option<u32>,
743    /// Target height in pixels
744    #[arg(long, value_parser = parse_height)]
745    height: Option<u32>,
746    /// How to fit into target dimensions
747    #[arg(long, value_parser = parse_fit)]
748    fit: Option<Fit>,
749    /// Crop anchor for cover mode
750    #[arg(long, value_parser = parse_position)]
751    position: Option<Position>,
752    /// Output format
753    #[arg(long, value_parser = parse_media_type)]
754    format: Option<MediaType>,
755    /// Encoding quality for lossy formats
756    #[arg(long, value_parser = parse_quality)]
757    quality: Option<u8>,
758    /// Optimization mode (none, auto, lossless, lossy)
759    #[arg(long, value_parser = parse_optimize_mode)]
760    optimize: Option<OptimizeMode>,
761    /// Perceptual target for lossy optimization
762    #[arg(long = "target-quality", value_parser = parse_target_quality)]
763    target_quality: Option<TargetQuality>,
764    /// Background color as RRGGBB or RRGGBBAA hex
765    #[arg(long, value_parser = parse_background)]
766    background: Option<Rgba8>,
767    /// Rotate clockwise by whole degrees; negative turns counter-clockwise
768    ///
769    /// `allow_hyphen_values` is what lets `--rotate -90` through: without it clap reads
770    /// the leading `-` as the start of another flag and rejects the value.
771    #[arg(long, value_parser = parse_rotation, allow_hyphen_values = true)]
772    rotate: Option<Rotation>,
773    /// Apply EXIF orientation
774    #[arg(long)]
775    auto_orient: bool,
776    /// Skip EXIF orientation correction
777    #[arg(long)]
778    no_auto_orient: bool,
779    /// Remove all metadata
780    #[arg(long)]
781    strip_metadata: bool,
782    /// Preserve EXIF, ICC, and other metadata
783    #[arg(long)]
784    keep_metadata: bool,
785    /// Preserve EXIF only
786    #[arg(long)]
787    preserve_exif: bool,
788    /// Explicit crop region as x,y,width,height
789    ///
790    /// `allow_hyphen_values` is here for the same reason it is on `--rotate`: without it
791    /// clap reads a leading `-` as the start of another flag and answers with its own
792    /// message, so a negative origin never reaches `parse_crop` and the caller is told to
793    /// pass the value after `--`, which turns the next word into an input path.
794    #[arg(long, value_parser = parse_crop, allow_hyphen_values = true)]
795    crop: Option<CropRegion>,
796    /// Apply Gaussian blur (sigma: 0.1-100.0)
797    #[arg(long, value_parser = parse_blur)]
798    blur: Option<f32>,
799    /// Apply sharpen filter (sigma: 0.1-100.0)
800    #[arg(long, value_parser = parse_sharpen)]
801    sharpen: Option<f32>,
802    /// Desaturate the image to grayscale
803    #[arg(long)]
804    grayscale: bool,
805    /// Never scale an image up to reach the requested size
806    #[arg(long)]
807    without_enlargement: bool,
808    /// Watermark image URL to composite onto the output
809    #[arg(long, value_parser = parse_url_value)]
810    watermark_url: Option<String>,
811    /// Watermark placement (default: bottom-right)
812    #[arg(long, value_parser = parse_position)]
813    watermark_position: Option<Position>,
814    /// Watermark opacity 1-100 (default: 50)
815    #[arg(long, value_parser = parse_watermark_opacity)]
816    watermark_opacity: Option<u8>,
817    /// Watermark margin from edge in pixels (default: 10)
818    #[arg(long, value_parser = parse_watermark_margin)]
819    watermark_margin: Option<u32>,
820    /// Named transform preset to apply
821    #[arg(long)]
822    preset: Option<String>,
823    /// Show help for sign
824    #[arg(short = 'h', long = "help")]
825    help: bool,
826}
827
828// ---------------------------------------------------------------------------
829// Clap value parsers for custom types
830// ---------------------------------------------------------------------------
831
832fn parse_fit(s: &str) -> Result<Fit, String> {
833    Fit::from_str(s)
834}
835
836/// The HTTP method a signed URL is minted for.
837///
838/// The signature covers the method, so a URL signed for `GET` is answered 401 for a `HEAD`
839/// and the two are separate URLs over the same transform. These are the only two methods
840/// `/images/by-path` and `/images/by-url` serve, so a third can only produce a URL that
841/// never verifies, which is why the parser refuses it rather than passing it through.
842#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
843enum SignedMethod {
844    #[default]
845    Get,
846    Head,
847}
848
849impl SignedMethod {
850    fn as_str(self) -> &'static str {
851        match self {
852            Self::Get => "GET",
853            Self::Head => "HEAD",
854        }
855    }
856}
857
858impl FromStr for SignedMethod {
859    type Err = String;
860
861    fn from_str(s: &str) -> Result<Self, Self::Err> {
862        // `@nao1215/truss-url-signer` uppercases before signing and so does
863        // `sign_public_url_with_method`, so a lowercase spelling has to reach the same URL
864        // here or the two signers disagree over the same input.
865        match s.to_ascii_uppercase().as_str() {
866            "GET" => Ok(Self::Get),
867            "HEAD" => Ok(Self::Head),
868            _ => Err(format!("unsupported signed URL method `{s}`")),
869        }
870    }
871}
872
873fn parse_signed_method(s: &str) -> Result<SignedMethod, String> {
874    SignedMethod::from_str(s)
875}
876
877fn parse_position(s: &str) -> Result<Position, String> {
878    Position::from_str(s)
879}
880
881/// Parses an output format, refusing formats truss can read but not write.
882///
883/// `--format gif` would otherwise parse cleanly and fail deep in the pipeline. Rejecting
884/// it here puts the error next to the flag the user typed and names the alternatives.
885fn parse_media_type(s: &str) -> Result<MediaType, String> {
886    let media_type = MediaType::from_str(s)?;
887    match media_type.unencodable_reason() {
888        Some(reason) => Err(reason),
889        None => Ok(media_type),
890    }
891}
892
893fn parse_optimizable_media_type(s: &str) -> Result<MediaType, String> {
894    let media_type = parse_media_type(s)?;
895    if media_type.supports_optimization() {
896        Ok(media_type)
897    } else {
898        Err(format!(
899            "optimization is not supported for {} output",
900            media_type.as_name()
901        ))
902    }
903}
904
905fn parse_optimize_mode(s: &str) -> Result<OptimizeMode, String> {
906    OptimizeMode::from_str(s)
907}
908
909/// The modes `truss optimize` takes, which is every mode that optimizes.
910///
911/// `none` re-encodes without optimizing, which on a subcommand whose purpose is to shrink a
912/// file is a way to make it bigger, and it skipped the format check the other three apply, so
913/// a TIFF output passed when the format was inferred and failed when it was named.
914/// `truss convert` is the command for a plain re-encode, and `OptimizeMode::None` stays the
915/// default there and on the other three adapters, where it means what it says.
916fn parse_optimizing_mode(s: &str) -> Result<OptimizeMode, String> {
917    match OptimizeMode::from_str(s)? {
918        OptimizeMode::None => Err(
919            "`none` does not optimize; use `truss convert` for a plain re-encode, or one of auto, lossless, lossy"
920                .to_string(),
921        ),
922        mode => Ok(mode),
923    }
924}
925
926fn parse_target_quality(s: &str) -> Result<TargetQuality, String> {
927    TargetQuality::from_str(s)
928}
929
930fn parse_rotation(s: &str) -> Result<Rotation, String> {
931    Rotation::from_str(s)
932}
933
934fn parse_background(s: &str) -> Result<Rgba8, String> {
935    Rgba8::from_hex(s)
936}
937
938fn parse_crop(s: &str) -> Result<CropRegion, String> {
939    CropRegion::from_str(s)
940}
941
942/// Parses a blur sigma, leaving the range to `TransformOptions::normalize`.
943///
944/// The same convention as [`parse_quality`] and [`parse_dimension`], and for the same
945/// reason: a number the option can hold is handed on for the one copy every adapter reaches
946/// to judge, so a sigma outside the range is `invalid-options` on the CLI as it already was
947/// over HTTP and in the Wasm package. Checking the range here made these two flags the only
948/// ones that reported the same mistake as `invalid-request`. A value that is not a number
949/// at all is refused here, since there is nothing to hand on.
950fn parse_blur(s: &str) -> Result<f32, String> {
951    parse_sigma(s, "blur")
952}
953
954/// The same for a sharpen sigma. See [`parse_blur`].
955fn parse_sharpen(s: &str) -> Result<f32, String> {
956    parse_sigma(s, "sharpen")
957}
958
959fn parse_sigma(s: &str, name: &str) -> Result<f32, String> {
960    s.parse::<f32>()
961        .map_err(|_| format!("{name} sigma must be a number, got '{s}'"))
962}
963
964/// Parses a quality, reporting the range truss documents whatever the number's width.
965///
966/// Parsed as an `i64` rather than as the `u8` the option is stored in, because clap would
967/// otherwise refuse 256 with `256 is not in 0..=255`, a range that is the integer's and not
968/// truss's, while 255 got `quality must be between 1 and 100`.
969/// Parses a watermark margin the same way, so a number too large to be a count of pixels
970/// says so rather than naming the integer it would be stored in.
971fn parse_watermark_margin(s: &str) -> Result<u32, String> {
972    parse_dimension(
973        s,
974        "watermark margin",
975        crate::core::validate_watermark_margin_value,
976    )
977}
978
979/// Parses a width, reporting the rule truss documents whatever the number's width.
980///
981/// Parsed as an `i64` rather than as the `u32` the option is stored in, because clap would
982/// otherwise refuse 4294967296 with `4294967296 is not in 0..=4294967295`, the span of the
983/// integer rather than anything truss publishes.
984fn parse_width(s: &str) -> Result<u32, String> {
985    parse_dimension(s, "width", crate::core::validate_width_value)
986}
987
988/// The same for a height. See [`parse_width`].
989fn parse_height(s: &str) -> Result<u32, String> {
990    parse_dimension(s, "height", crate::core::validate_height_value)
991}
992
993fn parse_dimension(
994    s: &str,
995    axis: &str,
996    validate: fn(i64) -> Result<u32, &'static str>,
997) -> Result<u32, String> {
998    let value: i64 = s
999        .parse()
1000        .map_err(|_| format!("{axis} must be a whole number of pixels, got '{s}'"))?;
1001    // A value the option can hold is handed on for the transform to judge, which keeps the
1002    // failure class the CLI reported before and the one the other adapters report.
1003    validate(value).map_err(str::to_string)
1004}
1005
1006fn parse_quality(s: &str) -> Result<u8, String> {
1007    let value: i64 = s
1008        .parse()
1009        .map_err(|_| format!("quality must be a whole number, got '{s}'"))?;
1010    // A value the option can hold is handed on for `TransformOptions::normalize` to judge,
1011    // which keeps the failure class the CLI reported before and the one the server reports
1012    // for the same number. One that cannot be held is refused here, with the sentence that
1013    // check would have given rather than with the range of the integer holding it.
1014    u8::try_from(value).map_err(|_| {
1015        crate::core::validate_quality_value(value)
1016            .expect_err("a value outside u8 is outside 1..=100")
1017            .to_string()
1018    })
1019}
1020
1021fn parse_watermark_opacity(s: &str) -> Result<u8, String> {
1022    let value: i64 = s
1023        .parse()
1024        .map_err(|_| format!("watermark opacity must be a whole number, got '{s}'"))?;
1025    crate::core::validate_watermark_opacity_value(value).map_err(str::to_string)
1026}
1027
1028fn parse_url_value(s: &str) -> Result<String, String> {
1029    let parsed = url::Url::parse(s).map_err(|e| format!("invalid URL: {e}"))?;
1030    match parsed.scheme() {
1031        "http" | "https" => Ok(s.to_string()),
1032        _ => Err(format!("requires an http:// or https:// URL, got '{s}'")),
1033    }
1034}
1035
1036// ---------------------------------------------------------------------------
1037// Usage strings (reused in errors)
1038// ---------------------------------------------------------------------------
1039
1040fn convert_usage() -> &'static str {
1041    "usage: truss convert <INPUT> -o <OUTPUT> [OPTIONS]"
1042}
1043
1044fn optimize_usage() -> &'static str {
1045    "usage: truss optimize <INPUT> -o <OUTPUT> [OPTIONS]"
1046}
1047
1048fn inspect_usage() -> &'static str {
1049    "usage: truss inspect <FILE|--url URL|->"
1050}
1051
1052fn serve_usage() -> &'static str {
1053    "usage: truss serve [--bind ADDR] [--storage-root PATH] [OPTIONS]"
1054}
1055
1056fn sign_usage() -> &'static str {
1057    "usage: truss sign --base-url <URL> (--path <PATH>|--url <URL>) --key-id <ID> --secret <SECRET> --expires <UNIX_SECS>"
1058}
1059
1060// ---------------------------------------------------------------------------
1061// Public entry point
1062// ---------------------------------------------------------------------------
1063
1064/// Runs the command-line adapter and returns a process exit code.
1065///
1066/// This function is the stable entry point for the CLI adapter. It parses command-line
1067/// arguments, dispatches the selected subcommand, writes output to the process streams,
1068/// and converts adapter-specific failures into the documented numeric exit codes.
1069///
1070/// Standard output is flushed before returning, so a write that only fails when the
1071/// buffer drains — a full disk, a quota, a reader that closed the pipe — is reported as
1072/// exit code 5 instead of being discarded by the runtime's exit-time flush.
1073///
1074/// # Examples
1075///
1076/// ```no_run
1077/// use truss::run_cli;
1078///
1079/// let _ = run_cli(vec![
1080///     "truss".to_string(),
1081///     "input.png".to_string(),
1082///     "-o".to_string(),
1083///     "output.jpg".to_string(),
1084/// ]);
1085/// ```
1086///
1087/// ```no_run
1088/// use truss::run_cli;
1089///
1090/// let _ = run_cli(vec![
1091///     "truss".to_string(),
1092///     "--bind".to_string(),
1093///     "127.0.0.1:8080".to_string(),
1094/// ]);
1095/// ```
1096pub fn run<I>(args: I) -> ExitCode
1097where
1098    I: IntoIterator<Item: Into<OsString>>,
1099{
1100    let stdin = io::stdin();
1101    let stdout = io::stdout();
1102    let stderr = io::stderr();
1103    let mut stdin = stdin.lock();
1104    let mut stdout = stdout.lock();
1105    let mut stderr = stderr.lock();
1106
1107    let code = run_with_io(args, &mut stdin, &mut stdout, &mut stderr);
1108
1109    ExitCode::from(flush_stdout(code, &mut stdout, &mut stderr))
1110}
1111
1112/// Flushes standard output and folds a flush failure into the exit code.
1113///
1114/// `StdoutLock` buffers. A payload that ends without a newline — a small WebP or AVIF
1115/// written to `-o -` — can sit entirely in that buffer, so the only write that reaches
1116/// the file descriptor is the runtime's flush after `main` returns, and nothing observes
1117/// its error. Flushing here turns that silent truncation into exit code 5 with a reason.
1118///
1119/// A command that already failed keeps its own exit code: the flush error is a
1120/// consequence of the first failure, not a second, more informative one.
1121fn flush_stdout<W, E>(code: u8, stdout: &mut W, stderr: &mut E) -> u8
1122where
1123    W: Write,
1124    E: Write,
1125{
1126    match stdout.flush() {
1127        Ok(()) => code,
1128        Err(error) if code == EXIT_SUCCESS => write_error(stderr, stdout_write_error(&error)),
1129        Err(_) => code,
1130    }
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Command types (internal)
1135// ---------------------------------------------------------------------------
1136
1137#[derive(Debug, Clone, PartialEq, Eq)]
1138enum HelpTopic {
1139    TopLevel,
1140    Convert,
1141    Optimize,
1142    Inspect,
1143    Serve,
1144    Validate,
1145    Sign,
1146    Completions,
1147    Version,
1148}
1149
1150#[derive(Debug, Clone, PartialEq)]
1151enum Command {
1152    Help(HelpTopic),
1153    Version,
1154    Serve(ServeCommand),
1155    Validate,
1156    Inspect(InspectCommand),
1157    Convert(ConvertCommand),
1158    Optimize(ConvertCommand),
1159    Sign(SignCommand),
1160    Completions(clap_complete::Shell),
1161}
1162
1163#[derive(Debug, Clone, PartialEq, Eq)]
1164struct ServeCommand {
1165    bind_addr: Option<String>,
1166    storage_root: Option<PathBuf>,
1167    public_base_url: Option<String>,
1168    signed_url_key_id: Option<String>,
1169    signed_url_secret: Option<String>,
1170    allow_insecure_url_sources: bool,
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1174struct InspectCommand {
1175    input: InputSource,
1176}
1177
1178#[derive(Debug, Clone, PartialEq)]
1179struct ConvertCommand {
1180    input: InputSource,
1181    output: OutputTarget,
1182    options: TransformOptions,
1183    watermark_path: Option<PathBuf>,
1184    watermark_position: Option<Position>,
1185    watermark_opacity: Option<u8>,
1186    watermark_margin: Option<u32>,
1187}
1188
1189#[derive(Debug, Clone, PartialEq)]
1190struct SignCommand {
1191    base_url: String,
1192    method: SignedMethod,
1193    source: SignedUrlSource,
1194    key_id: String,
1195    secret: String,
1196    expires: u64,
1197    options: TransformOptions,
1198    watermark_url: Option<String>,
1199    watermark_position: Option<Position>,
1200    watermark_opacity: Option<u8>,
1201    watermark_margin: Option<u32>,
1202    preset: Option<String>,
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq)]
1206enum InputSource {
1207    Stdin,
1208    Path(PathBuf),
1209    Url(String),
1210}
1211
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213enum OutputTarget {
1214    Stdout,
1215    Path(PathBuf),
1216}
1217
1218// ---------------------------------------------------------------------------
1219// Structured error
1220// ---------------------------------------------------------------------------
1221
1222/// A failure on its way to standard error, carrying both halves of how the CLI reports one.
1223///
1224/// `exit_code` is the coarse signal a shell branches on, one of the five in CONTRIBUTING.md.
1225/// `class` is the same failure named the way the HTTP server and the Wasm package name it,
1226/// so a caller who moves a transform between the three adapters keeps one classification;
1227/// it is what `write_error` prints in parentheses.
1228#[derive(Debug, Clone, PartialEq, Eq)]
1229struct CliError {
1230    exit_code: u8,
1231    class: ErrorClass,
1232    message: String,
1233    usage: Option<String>,
1234    hint: Option<String>,
1235}
1236
1237// ---------------------------------------------------------------------------
1238// Core dispatch
1239// ---------------------------------------------------------------------------
1240
1241fn run_with_io<I, R, W, E>(args: I, stdin: &mut R, stdout: &mut W, stderr: &mut E) -> u8
1242where
1243    I: IntoIterator<Item: Into<OsString>>,
1244    R: Read,
1245    W: Write,
1246    E: Write,
1247{
1248    match parse_args(args) {
1249        Ok(Command::Help(topic)) => {
1250            let text = match topic {
1251                HelpTopic::TopLevel => help_top_level(),
1252                HelpTopic::Convert => HELP_CONVERT.to_string(),
1253                HelpTopic::Optimize => HELP_OPTIMIZE.to_string(),
1254                HelpTopic::Inspect => HELP_INSPECT.to_string(),
1255                HelpTopic::Serve => help_serve(),
1256                HelpTopic::Validate => HELP_VALIDATE.to_string(),
1257                HelpTopic::Sign => HELP_SIGN.to_string(),
1258                HelpTopic::Completions => HELP_COMPLETIONS.to_string(),
1259                HelpTopic::Version => HELP_VERSION.to_string(),
1260            };
1261            match stdout.write_all(text.as_bytes()) {
1262                Ok(()) => EXIT_SUCCESS,
1263                Err(error) => write_error(stderr, stdout_write_error(&error)),
1264            }
1265        }
1266        Ok(Command::Version) => match writeln!(stdout, "truss {}", env!("CARGO_PKG_VERSION")) {
1267            Ok(()) => EXIT_SUCCESS,
1268            Err(error) => write_error(stderr, stdout_write_error(&error)),
1269        },
1270        Ok(Command::Serve(command)) => match serve::execute_serve(command) {
1271            Ok(()) => EXIT_SUCCESS,
1272            Err(error) => write_error(stderr, error),
1273        },
1274        Ok(Command::Validate) => match serve::execute_validate(stdout) {
1275            Ok(()) => EXIT_SUCCESS,
1276            Err(error) => write_error(stderr, error),
1277        },
1278        Ok(Command::Inspect(command)) => match inspect::execute_inspect(command, stdin, stdout) {
1279            Ok(()) => EXIT_SUCCESS,
1280            Err(error) => write_error(stderr, error),
1281        },
1282        Ok(Command::Convert(command) | Command::Optimize(command)) => {
1283            match convert::execute_convert(command, stdin, stdout) {
1284                Ok(()) => EXIT_SUCCESS,
1285                Err(error) => write_error(stderr, error),
1286            }
1287        }
1288        Ok(Command::Sign(command)) => match sign::execute_sign(command, stdout) {
1289            Ok(()) => EXIT_SUCCESS,
1290            Err(error) => write_error(stderr, error),
1291        },
1292        Ok(Command::Completions(shell)) => match generate_completions(shell, stdout) {
1293            Ok(()) => EXIT_SUCCESS,
1294            Err(error) => write_error(stderr, error),
1295        },
1296        Err(error) => write_error(stderr, error),
1297    }
1298}
1299
1300// ---------------------------------------------------------------------------
1301// Argument preprocessing for implicit convert / serve
1302// ---------------------------------------------------------------------------
1303
1304const KNOWN_SUBCOMMANDS: &[&str] = &[
1305    "convert",
1306    "optimize",
1307    "inspect",
1308    "serve",
1309    "validate",
1310    "sign",
1311    "help",
1312    "completions",
1313    "version",
1314];
1315
1316fn is_serve_flag(value: &str) -> bool {
1317    matches!(
1318        value,
1319        "--bind"
1320            | "--storage-root"
1321            | "--public-base-url"
1322            | "--signed-url-key-id"
1323            | "--signed-url-secret"
1324            | "--allow-insecure-url-sources"
1325    )
1326}
1327
1328/// Returns `true` when a token looks like it was meant to be a subcommand name
1329/// (starts with a letter, no path separators, no file extension).
1330fn looks_like_unknown_subcommand(value: &str) -> bool {
1331    if value.starts_with('-') || value.starts_with('/') || value.starts_with('.') {
1332        return false;
1333    }
1334    if value.contains('.') || value.contains('/') || value.contains('\\') {
1335        return false;
1336    }
1337    value
1338        .chars()
1339        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1340}
1341
1342/// Pre-processes raw args to handle implicit convert and implicit serve
1343/// before handing off to clap.
1344/// Reports whether a path argument is the single dash that names standard input or output.
1345///
1346/// The comparison is on the bytes rather than on a `str`, so a path that is not text
1347/// reaches the file system rather than being read as a stream.
1348fn is_dash(path: &Path) -> bool {
1349    path.as_os_str() == OsStr::new("-")
1350}
1351
1352fn preprocess_args(args: Vec<OsString>) -> Vec<OsString> {
1353    if args.len() <= 1 {
1354        return args;
1355    }
1356    let first = &args[1];
1357    // A path is bytes on Unix, so an argument that is not text is still a file truss can
1358    // read. Only the routing decisions below need it as text, and every one of them is a
1359    // comparison against a name truss chose, which no such argument can match.
1360    let first_text = first.to_str();
1361
1362    // -h / --help at top level → route to our help subcommand
1363    if first == "-h" || first == "--help" {
1364        let mut new = vec![args[0].clone(), OsString::from("help")];
1365        if args.len() > 2 {
1366            new.extend_from_slice(&args[2..]);
1367        }
1368        return new;
1369    }
1370
1371    // -V / --version at top level → route to version subcommand
1372    if first == "-V" || first == "--version" {
1373        return vec![args[0].clone(), OsString::from("version")];
1374    }
1375
1376    // If first arg is a serve flag, insert "serve" subcommand
1377    if first_text.is_some_and(is_serve_flag) {
1378        let mut new = vec![args[0].clone(), OsString::from("serve")];
1379        new.extend_from_slice(&args[1..]);
1380        return new;
1381    }
1382
1383    // Known subcommand → pass through
1384    if first_text.is_some_and(|first| KNOWN_SUBCOMMANDS.contains(&first)) {
1385        return args;
1386    }
1387
1388    // If the first argument refers to an existing file (even without an
1389    // extension), treat it as an implicit convert rather than an unknown
1390    // subcommand.  This handles `truss image -o out.jpg` where `image` is a
1391    // real file.
1392    if std::path::Path::new(first).is_file() {
1393        let mut new = vec![args[0].clone(), OsString::from("convert")];
1394        new.extend_from_slice(&args[1..]);
1395        return new;
1396    }
1397
1398    // Looks like an unknown subcommand (alphabetic, no dots/slashes) →
1399    // let clap handle it for typo suggestions
1400    if first_text.is_some_and(looks_like_unknown_subcommand) {
1401        return args;
1402    }
1403
1404    // Otherwise, treat as implicit convert
1405    let mut new = vec![args[0].clone(), OsString::from("convert")];
1406    new.extend_from_slice(&args[1..]);
1407    new
1408}
1409
1410// ---------------------------------------------------------------------------
1411// Argument parsing — main entry
1412// ---------------------------------------------------------------------------
1413
1414fn parse_args<I>(args: I) -> Result<Command, CliError>
1415where
1416    I: IntoIterator<Item: Into<OsString>>,
1417{
1418    let raw: Vec<OsString> = args.into_iter().map(Into::into).collect();
1419
1420    // Bare invocation → top-level help (exit 0)
1421    if raw.len() <= 1 {
1422        return Ok(Command::Help(HelpTopic::TopLevel));
1423    }
1424
1425    let preprocessed = preprocess_args(raw);
1426    let cli = Cli::try_parse_from(&preprocessed).map_err(map_clap_error)?;
1427
1428    match cli.command {
1429        None => Ok(Command::Help(HelpTopic::TopLevel)),
1430        Some(CliSubcommand::Help { topic }) => parse_help_topic(topic),
1431        Some(CliSubcommand::Version) => Ok(Command::Version),
1432        Some(CliSubcommand::Completions { help: true, .. }) => {
1433            Ok(Command::Help(HelpTopic::Completions))
1434        }
1435        Some(CliSubcommand::Completions {
1436            shell: Some(shell), ..
1437        }) => Ok(Command::Completions(shell)),
1438        Some(CliSubcommand::Completions {
1439            shell: None,
1440            help: false,
1441        }) => Err(CliError {
1442            exit_code: EXIT_USAGE,
1443            class: ErrorClass::InvalidRequest,
1444            message: "'completions' requires a shell argument".to_string(),
1445            usage: None,
1446            hint: Some("try 'truss completions bash'".to_string()),
1447        }),
1448        Some(CliSubcommand::Convert(args)) => convert::convert_from_clap(args),
1449        Some(CliSubcommand::Optimize(args)) => convert::optimize_from_clap(args),
1450        Some(CliSubcommand::Inspect(args)) => inspect::inspect_from_clap(args),
1451        Some(CliSubcommand::Serve(args)) => serve::serve_from_clap(args),
1452        Some(CliSubcommand::Validate(args)) => serve::validate_from_clap(args),
1453        Some(CliSubcommand::Sign(args)) => sign::sign_from_clap(args),
1454    }
1455}
1456
1457/// Maps a clap error into a structured `CliError`.
1458fn map_clap_error(err: clap::Error) -> CliError {
1459    let raw = err.to_string();
1460    // clap renders "error: ..." — strip that prefix since write_error adds its own
1461    let message = raw
1462        .strip_prefix("error: ")
1463        .unwrap_or(&raw)
1464        .trim()
1465        .to_string();
1466
1467    CliError {
1468        exit_code: EXIT_USAGE,
1469        class: ErrorClass::InvalidRequest,
1470        message,
1471        usage: None,
1472        hint: Some("run 'truss --help' for available commands".to_string()),
1473    }
1474}
1475
1476fn parse_help_topic(topic: Option<String>) -> Result<Command, CliError> {
1477    match topic.as_deref() {
1478        None => Ok(Command::Help(HelpTopic::TopLevel)),
1479        Some("convert") => Ok(Command::Help(HelpTopic::Convert)),
1480        Some("optimize") => Ok(Command::Help(HelpTopic::Optimize)),
1481        Some("inspect") => Ok(Command::Help(HelpTopic::Inspect)),
1482        Some("serve") => Ok(Command::Help(HelpTopic::Serve)),
1483        Some("validate") => Ok(Command::Help(HelpTopic::Validate)),
1484        Some("sign") => Ok(Command::Help(HelpTopic::Sign)),
1485        Some("completions") => Ok(Command::Help(HelpTopic::Completions)),
1486        Some("version") => Ok(Command::Help(HelpTopic::Version)),
1487        Some(other) => Err(CliError {
1488            exit_code: EXIT_USAGE,
1489            class: ErrorClass::InvalidRequest,
1490            message: format!("unknown help topic '{other}'"),
1491            usage: None,
1492            hint: Some(
1493                "available topics: convert, optimize, inspect, serve, validate, sign, completions, version"
1494                    .to_string(),
1495            ),
1496        }),
1497    }
1498}
1499
1500// ---------------------------------------------------------------------------
1501// Shared transform fields
1502// ---------------------------------------------------------------------------
1503
1504/// Collects shared transform fields from clap args into `TransformOptions`.
1505struct TransformFields {
1506    width: Option<u32>,
1507    height: Option<u32>,
1508    fit: Option<Fit>,
1509    position: Option<Position>,
1510    format: Option<MediaType>,
1511    quality: Option<u8>,
1512    optimize: Option<OptimizeMode>,
1513    target_quality: Option<TargetQuality>,
1514    background: Option<Rgba8>,
1515    rotate: Option<Rotation>,
1516    auto_orient: bool,
1517    no_auto_orient: bool,
1518    strip_metadata: bool,
1519    keep_metadata: bool,
1520    preserve_exif: bool,
1521    crop: Option<CropRegion>,
1522    blur: Option<f32>,
1523    sharpen: Option<f32>,
1524    grayscale: bool,
1525    without_enlargement: bool,
1526}
1527
1528impl TransformFields {
1529    fn into_options(self) -> Result<TransformOptions, crate::TransformError> {
1530        let defaults = TransformOptions::default();
1531        let auto_orient = if self.no_auto_orient {
1532            false
1533        } else if self.auto_orient {
1534            true
1535        } else {
1536            defaults.auto_orient
1537        };
1538        let (strip_metadata, preserve_exif) = crate::core::resolve_metadata_flags(
1539            if self.strip_metadata {
1540                Some(true)
1541            } else {
1542                None
1543            },
1544            if self.keep_metadata { Some(true) } else { None },
1545            if self.preserve_exif { Some(true) } else { None },
1546        )?;
1547        Ok(TransformOptions {
1548            width: self.width,
1549            height: self.height,
1550            fit: self.fit,
1551            position: self.position,
1552            format: self.format,
1553            quality: self.quality,
1554            optimize: self.optimize.unwrap_or(defaults.optimize),
1555            target_quality: self.target_quality,
1556            background: self.background,
1557            rotate: self.rotate.unwrap_or(defaults.rotate),
1558            auto_orient,
1559            strip_metadata,
1560            preserve_exif,
1561            crop: self.crop,
1562            blur: self.blur,
1563            sharpen: self.sharpen,
1564            grayscale: self.grayscale,
1565            without_enlargement: self.without_enlargement,
1566            deadline: None,
1567        })
1568    }
1569}
1570
1571fn validate_url(url: &str, flag: &str) -> Result<(), CliError> {
1572    let parsed = url::Url::parse(url).map_err(|e| CliError {
1573        exit_code: EXIT_USAGE,
1574        class: ErrorClass::InvalidRequest,
1575        message: format!("'{flag}' is not a valid URL: {e}"),
1576        usage: None,
1577        hint: Some(format!("got '{url}'")),
1578    })?;
1579    match parsed.scheme() {
1580        "http" | "https" => Ok(()),
1581        _ => Err(CliError {
1582            exit_code: EXIT_USAGE,
1583            class: ErrorClass::InvalidRequest,
1584            message: format!("'{flag}' requires an http:// or https:// URL"),
1585            usage: None,
1586            hint: Some(format!("got '{url}'")),
1587        }),
1588    }
1589}
1590
1591/// Generates shell completion scripts for the given shell.
1592fn generate_completions<W: Write>(
1593    shell: clap_complete::Shell,
1594    stdout: &mut W,
1595) -> Result<(), CliError> {
1596    let mut cmd = Cli::command();
1597
1598    // Add implicit-convert positional argument and common flags so that shell
1599    // completions expose the shorthand forms documented in the help text
1600    // (e.g. `truss photo.png -o out.jpg`, `truss --bind 0.0.0.0:8080`).
1601    cmd = cmd
1602        .arg(
1603            clap::Arg::new("INPUT")
1604                .help("Input image file (implicit convert)")
1605                .value_hint(clap::ValueHint::FilePath),
1606        )
1607        .arg(
1608            clap::Arg::new("output")
1609                .short('o')
1610                .long("output")
1611                .help("Output file path (implicit convert)")
1612                .value_hint(clap::ValueHint::FilePath),
1613        )
1614        .arg(
1615            clap::Arg::new("bind")
1616                .long("bind")
1617                .help("Listen address (implicit serve)"),
1618        )
1619        .arg(
1620            clap::Arg::new("storage-root")
1621                .long("storage-root")
1622                .help("Root directory for path-based sources (implicit serve)")
1623                .value_hint(clap::ValueHint::DirPath),
1624        );
1625
1626    clap_complete::generate(shell, &mut cmd, "truss", stdout);
1627    Ok(())
1628}
1629
1630// ---------------------------------------------------------------------------
1631// I/O helpers
1632// ---------------------------------------------------------------------------
1633
1634fn read_input_bytes<R>(input: InputSource, stdin: &mut R) -> Result<Vec<u8>, CliError>
1635where
1636    R: Read,
1637{
1638    match input {
1639        InputSource::Stdin => {
1640            let mut bytes = Vec::new();
1641            stdin.read_to_end(&mut bytes).map_err(|error| {
1642                runtime_error(EXIT_IO, &format!("failed to read stdin: {error}"))
1643            })?;
1644            Ok(bytes)
1645        }
1646        InputSource::Path(path) => fs::read(&path).map_err(|error| {
1647            classified_error(
1648                class_for_io_error(&error),
1649                EXIT_IO,
1650                &format!("failed to read {}: {error}", path.display()),
1651            )
1652        }),
1653        InputSource::Url(url) => read_url_bytes(&url, MAX_REMOTE_BYTES),
1654    }
1655}
1656
1657/// Names the class of a file system fault.
1658///
1659/// A source that is not there is `not-found`, the class the server gives the same miss and
1660/// the one `docs/problems.md` describes as an input file that is not there; anything else
1661/// about the file system is `internal-error`. Every path the command line names is read
1662/// through this, so a mistyped `--watermark` is classified like a mistyped input.
1663fn class_for_io_error(error: &io::Error) -> ErrorClass {
1664    if error.kind() == io::ErrorKind::NotFound {
1665        ErrorClass::NotFound
1666    } else {
1667        ErrorClass::InternalError
1668    }
1669}
1670
1671/// Timeout for the TCP connect phase of a remote fetch.
1672const CLI_FETCH_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1673/// Timeout for receiving the full response body from a remote source.
1674const CLI_FETCH_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
1675/// How many redirects a `--url` fetch follows before giving up.
1676///
1677/// The same number the server allows, so a URL that works against one works against the
1678/// other.
1679const CLI_FETCH_MAX_REDIRECTS: u32 = 5;
1680
1681/// Refuses a URL truss may not fetch, whichever hop of a redirect chain named it.
1682///
1683/// A command line is expected to fetch from the machine it runs on, so the private and
1684/// loopback ranges the server refuses are allowed here. The metadata endpoints are not:
1685/// `docs/configuration.md` calls them blocked whatever else is configured, no workflow
1686/// fetches an image from one, and the URL that reaches one is chosen by whatever server
1687/// answered the previous hop rather than by whoever typed the command.
1688///
1689/// The name is checked, and so are the addresses it resolves to, which covers a host that
1690/// points at a metadata address without spelling it. A host whose answer changes between
1691/// this lookup and the agent's own is not covered: the server closes that with DNS pinning,
1692/// which it can only do because it refuses every private address, and this adapter does not.
1693fn refuse_disallowed_fetch_target(url: &str) -> Result<(), CliError> {
1694    let parsed = url::Url::parse(url).map_err(|error| {
1695        classified_error(
1696            ErrorClass::BadGateway,
1697            EXIT_IO,
1698            &format!("failed to fetch {url}: not a valid URL: {error}"),
1699        )
1700    })?;
1701    if !matches!(parsed.scheme(), "http" | "https") {
1702        return Err(classified_error(
1703            ErrorClass::BadGateway,
1704            EXIT_IO,
1705            &format!(
1706                "failed to fetch {url}: a redirect to a `{}` URL is not followed",
1707                parsed.scheme()
1708            ),
1709        ));
1710    }
1711    let named_metadata = crate::core::remote_policy::is_cloud_metadata_host(&parsed);
1712    // A host name under someone else's control can resolve to a metadata address without
1713    // spelling it, so the addresses it resolves to are checked as well.
1714    let resolves_to_metadata = !named_metadata
1715        && parsed
1716            .socket_addrs(|| Some(if parsed.scheme() == "https" { 443 } else { 80 }))
1717            .map(|addrs| {
1718                addrs
1719                    .iter()
1720                    .any(|addr| crate::core::remote_policy::is_cloud_metadata_ip(addr.ip()))
1721            })
1722            .unwrap_or(false);
1723    if named_metadata || resolves_to_metadata {
1724        return Err(classified_error(
1725            ErrorClass::BadGateway,
1726            EXIT_IO,
1727            &format!("failed to fetch {url}: the URL points to a cloud metadata service"),
1728        ));
1729    }
1730    Ok(())
1731}
1732
1733/// Fetches `--url` input.
1734///
1735/// Redirects are followed here rather than inside the agent so that every hop is checked:
1736/// the caller chooses the first URL and a remote server chooses the rest.
1737///
1738/// Every failure here is the remote end's, which the server names `bad-gateway`; the CLI
1739/// keeps its I/O exit code (2) and adds that class.
1740fn read_url_bytes(url: &str, max_bytes: u64) -> Result<Vec<u8>, CliError> {
1741    let fetch_failed =
1742        |message: String| classified_error(ErrorClass::BadGateway, EXIT_IO, &message);
1743    let config = ureq::config::Config::builder()
1744        .timeout_connect(Some(CLI_FETCH_CONNECT_TIMEOUT))
1745        .timeout_recv_body(Some(CLI_FETCH_BODY_TIMEOUT))
1746        .http_status_as_error(false)
1747        .max_redirects(0)
1748        .build();
1749    let agent = ureq::Agent::new_with_config(config);
1750
1751    let mut current = url.to_string();
1752    let mut response = None;
1753    for _ in 0..=CLI_FETCH_MAX_REDIRECTS {
1754        refuse_disallowed_fetch_target(&current)?;
1755        let hop = agent
1756            .get(&current)
1757            .call()
1758            .map_err(|error| fetch_failed(format!("failed to fetch {current}: {error}")))?;
1759        let status = hop.status().as_u16();
1760        if crate::core::remote_policy::is_redirect_status(status) {
1761            let location = hop
1762                .headers()
1763                .get("Location")
1764                .and_then(|value: &ureq::http::HeaderValue| value.to_str().ok())
1765                .ok_or_else(|| {
1766                    fetch_failed(format!(
1767                        "failed to fetch {current}: HTTP {status} without a Location header"
1768                    ))
1769                })?;
1770            let base = url::Url::parse(&current).map_err(|error| {
1771                fetch_failed(format!(
1772                    "failed to fetch {current}: not a valid URL: {error}"
1773                ))
1774            })?;
1775            let next = base.join(location).map_err(|error| {
1776                fetch_failed(format!(
1777                    "failed to fetch {current}: redirect to an unusable URL: {error}"
1778                ))
1779            })?;
1780            current = next.into();
1781            continue;
1782        }
1783        response = Some((hop, status));
1784        break;
1785    }
1786
1787    let Some((response, status)) = response else {
1788        return Err(fetch_failed(format!(
1789            "failed to fetch {url}: more than {CLI_FETCH_MAX_REDIRECTS} redirects"
1790        )));
1791    };
1792
1793    // Only a 2xx says the body that follows is the resource, which is the rule the HTTP
1794    // server's own fetch applies. Reading an image out of anything else reports the origin
1795    // declining as a problem with the caller's file, and reports it as a success whenever
1796    // the body happens to sniff as an image.
1797    if !crate::core::remote_policy::is_success_status(status) {
1798        return Err(fetch_failed(format!(
1799            "failed to fetch {current}: HTTP {status}"
1800        )));
1801    }
1802
1803    if let Some(encoding) = response
1804        .headers()
1805        .get("Content-Encoding")
1806        .and_then(|value: &ureq::http::HeaderValue| value.to_str().ok())
1807        .and_then(crate::core::remote_policy::unreadable_content_coding)
1808    {
1809        return Err(fetch_failed(format!(
1810            "failed to fetch {current}: response uses unsupported content-encoding `{encoding}`"
1811        )));
1812    }
1813
1814    if response
1815        .headers()
1816        .get("Content-Length")
1817        .and_then(|v: &ureq::http::HeaderValue| v.to_str().ok())
1818        .and_then(|value: &str| value.parse::<u64>().ok())
1819        .is_some_and(|len| len > max_bytes)
1820    {
1821        return Err(fetch_failed(format!(
1822            "failed to fetch {url}: response exceeds {max_bytes} bytes"
1823        )));
1824    }
1825
1826    // The declared length is a claim; this is the measurement, so a response that declares
1827    // nothing, or declares a small number and sends more, is bounded by the same cap.
1828    let mut reader = response.into_body().into_reader().take(max_bytes + 1);
1829    let mut bytes = Vec::new();
1830    reader
1831        .read_to_end(&mut bytes)
1832        .map_err(|error| fetch_failed(format!("failed to fetch {url}: {error}")))?;
1833
1834    if bytes.len() as u64 > max_bytes {
1835        return Err(fetch_failed(format!(
1836            "failed to fetch {url}: response exceeds {max_bytes} bytes"
1837        )));
1838    }
1839
1840    Ok(bytes)
1841}
1842
1843/// Maps a transform failure onto the exit code and the class that name it.
1844///
1845/// The class is [`crate::TransformError::class`], the table the HTTP server and the Wasm
1846/// package read too, so the three adapters classify one failure the same way. The exit code
1847/// is the CLI's own column of that table and `map_transform_error_matches_the_class_table`
1848/// is what keeps the two in step with `docs/problems.md`.
1849fn map_transform_error(error: crate::TransformError) -> CliError {
1850    let class = error.class();
1851    let (exit_code, message) = match error {
1852        crate::TransformError::InvalidOptions(reason) => (EXIT_USAGE, reason),
1853        crate::TransformError::InvalidInput(reason) => (EXIT_INPUT, reason),
1854        // An input truss cannot process is an input error (3), the same class the
1855        // documented exit-code table gives an unsupported format, not a transform
1856        // failure (4).
1857        crate::TransformError::UnsupportedInputMediaType(reason) => (EXIT_INPUT, reason),
1858        crate::TransformError::DecodeFailed(reason)
1859        | crate::TransformError::EncodeFailed(reason)
1860        | crate::TransformError::CapabilityMissing(reason)
1861        | crate::TransformError::LimitExceeded(reason) => (EXIT_TRANSFORM, reason),
1862        // The error's own Display names the rule that was hit (svg needs an svg input,
1863        // gif is never encoded), so the CLI does not restate it in different words.
1864        ref error @ crate::TransformError::UnsupportedOutputMediaType(_) => {
1865            (EXIT_TRANSFORM, error.to_string())
1866        }
1867    };
1868    classified_error(class, exit_code, &message)
1869}
1870
1871// ---------------------------------------------------------------------------
1872// Error constructors
1873// ---------------------------------------------------------------------------
1874
1875fn convert_error(message: &str) -> CliError {
1876    CliError {
1877        exit_code: EXIT_USAGE,
1878        class: ErrorClass::InvalidRequest,
1879        message: message.to_string(),
1880        usage: Some(convert_usage().to_string()),
1881        hint: Some("run 'truss convert --help' for convert options".to_string()),
1882    }
1883}
1884
1885fn optimize_error(message: &str) -> CliError {
1886    CliError {
1887        exit_code: EXIT_USAGE,
1888        class: ErrorClass::InvalidRequest,
1889        message: message.to_string(),
1890        usage: Some(optimize_usage().to_string()),
1891        hint: Some("run 'truss optimize --help' for optimize options".to_string()),
1892    }
1893}
1894
1895fn sign_error(message: &str) -> CliError {
1896    CliError {
1897        exit_code: EXIT_USAGE,
1898        class: ErrorClass::InvalidRequest,
1899        message: message.to_string(),
1900        usage: Some(sign_usage().to_string()),
1901        hint: Some("run 'truss sign --help' for sign options".to_string()),
1902    }
1903}
1904
1905/// A command line that could not be understood or is contradictory: exit 1, the
1906/// `invalid-request` class, with no usage block of its own.
1907fn usage_error(message: &str) -> CliError {
1908    classified_error(ErrorClass::InvalidRequest, EXIT_USAGE, message)
1909}
1910
1911/// A failure the process could not avoid: an I/O fault (exit 2) or a runtime fault such as
1912/// a port already in use or a closed standard output (exit 5).
1913///
1914/// Both are the `internal-error` class, which is what the server reports for the same
1915/// faults. The I/O paths that know more than that — a source that is not there, a fetch the
1916/// remote end refused — say so with [`classified_error`] instead.
1917fn runtime_error(exit_code: u8, message: &str) -> CliError {
1918    classified_error(ErrorClass::InternalError, exit_code, message)
1919}
1920
1921/// Builds an error whose class the caller names.
1922fn classified_error(class: ErrorClass, exit_code: u8, message: &str) -> CliError {
1923    CliError {
1924        exit_code,
1925        class,
1926        message: message.to_string(),
1927        usage: None,
1928        hint: None,
1929    }
1930}
1931
1932/// Builds the error a failed write to standard output reports.
1933///
1934/// `help` and `version` used to return exit code 5 with nothing on stderr, so a redirect
1935/// into a full disk looked like a silent failure while every other command explained
1936/// itself. They now report the same way `convert` and `inspect` do.
1937fn stdout_write_error(error: &io::Error) -> CliError {
1938    runtime_error(EXIT_RUNTIME, &format!("failed to write stdout: {error}"))
1939}
1940
1941fn write_error<E>(stderr: &mut E, error: CliError) -> u8
1942where
1943    E: Write,
1944{
1945    let _ = writeln!(
1946        stderr,
1947        "error: {} ({})",
1948        crate::core::single_line(&error.message),
1949        error.class.slug()
1950    );
1951    if let Some(usage) = &error.usage {
1952        let _ = writeln!(stderr, "{usage}");
1953    }
1954    if let Some(hint) = &error.hint {
1955        let _ = writeln!(stderr, "hint: {hint}");
1956    }
1957    error.exit_code
1958}
1959
1960// ---------------------------------------------------------------------------
1961// Tests
1962// ---------------------------------------------------------------------------
1963
1964#[cfg(test)]
1965mod tests {
1966    use super::serve::resolve_server_config;
1967    use super::{
1968        Command, ConvertCommand, EXIT_INPUT, EXIT_IO, EXIT_TRANSFORM, EXIT_USAGE, HelpTopic,
1969        InputSource, MAX_REMOTE_BYTES, MAX_REMOTE_WATERMARK_BYTES, OutputTarget, ServeCommand,
1970        SignCommand, flush_stdout, parse_args, parse_optimize_mode, parse_optimizing_mode,
1971        preprocess_args, run_with_io,
1972    };
1973    use crate::{
1974        Fit, MediaType, OptimizeMode, RawArtifact, SignedUrlSource, TransformOptions,
1975        sniff_artifact,
1976    };
1977    use rstest::rstest;
1978    use serial_test::serial;
1979    use std::env;
1980    use std::ffi::OsString;
1981    use std::fs;
1982    use std::io::{self, Cursor, Read, Write};
1983    use std::net::TcpListener;
1984    use std::path::PathBuf;
1985    use std::thread;
1986
1987    fn png_bytes() -> Vec<u8> {
1988        crate::test_support::flat_png(4, 3)
1989    }
1990
1991    fn temp_file_path(name: &str) -> PathBuf {
1992        crate::test_support::unique_temp_path(&format!("truss-{name}")).with_extension("bin")
1993    }
1994
1995    fn temp_dir(name: &str) -> PathBuf {
1996        let path = crate::test_support::unique_temp_path(&format!("truss-{name}"));
1997        fs::create_dir_all(&path).expect("create temp dir");
1998        path
1999    }
2000
2001    fn spawn_http_server(
2002        body: Vec<u8>,
2003        content_type: &'static str,
2004    ) -> (String, thread::JoinHandle<()>) {
2005        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http test server");
2006        let addr = listener.local_addr().expect("server addr");
2007        let url = format!("http://{addr}/image");
2008
2009        let handle = thread::spawn(move || {
2010            let (mut stream, _) = listener.accept().expect("accept connection");
2011            let mut request = [0_u8; 1024];
2012            let _ = stream.read(&mut request);
2013            let header = format!(
2014                "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2015                body.len()
2016            );
2017            stream.write_all(header.as_bytes()).expect("write headers");
2018            stream.write_all(&body).expect("write body");
2019            stream.flush().expect("flush response");
2020        });
2021
2022        (url, handle)
2023    }
2024
2025    /// Serves one response with a caller-chosen status line and an image body.
2026    ///
2027    /// The body is always an image so the status is the only thing that varies, which is
2028    /// what makes the boundary between a response that is the resource and one that is not
2029    /// visible in the outcome.
2030    fn spawn_http_server_with_status(status: &'static str) -> (String, thread::JoinHandle<()>) {
2031        let listener = TcpListener::bind("127.0.0.1:0").expect("bind http test server");
2032        let addr = listener.local_addr().expect("server addr");
2033        let url = format!("http://{addr}/image");
2034        let body = crate::test_support::flat_png(4, 3);
2035
2036        let handle = thread::spawn(move || {
2037            let (mut stream, _) = listener.accept().expect("accept connection");
2038            let mut request = [0_u8; 1024];
2039            let _ = stream.read(&mut request);
2040            let header = format!(
2041                "HTTP/1.1 {status}\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2042                body.len()
2043            );
2044            stream.write_all(header.as_bytes()).expect("write headers");
2045            stream.write_all(&body).expect("write body");
2046            stream.flush().expect("flush response");
2047        });
2048
2049        (url, handle)
2050    }
2051
2052    // ===== Mandatory test 1: bare invocation shows top-level help and succeeds =====
2053
2054    #[test]
2055    fn bare_invocation_shows_top_level_help() {
2056        let mut stdin = Cursor::new(Vec::<u8>::new());
2057        let mut stdout = Vec::new();
2058        let mut stderr = Vec::new();
2059
2060        let exit_code = run_with_io(
2061            vec!["truss".to_string()],
2062            &mut stdin,
2063            &mut stdout,
2064            &mut stderr,
2065        );
2066
2067        assert_eq!(exit_code, 0);
2068        assert!(stderr.is_empty());
2069        let output = String::from_utf8(stdout).expect("utf8 stdout");
2070        assert!(output.contains("COMMANDS:"));
2071        assert!(output.contains("convert"));
2072        assert!(output.contains("inspect"));
2073        assert!(output.contains("serve"));
2074        assert!(output.contains("sign"));
2075    }
2076
2077    // ===== Mandatory test 2: --help shows top-level help =====
2078
2079    #[test]
2080    fn dash_dash_help_shows_top_level_help() {
2081        let mut stdin = Cursor::new(Vec::<u8>::new());
2082        let mut stdout = Vec::new();
2083        let mut stderr = Vec::new();
2084
2085        let exit_code = run_with_io(
2086            vec!["truss".to_string(), "--help".to_string()],
2087            &mut stdin,
2088            &mut stdout,
2089            &mut stderr,
2090        );
2091
2092        assert_eq!(exit_code, 0);
2093        assert!(stderr.is_empty());
2094        let output = String::from_utf8(stdout).expect("utf8 stdout");
2095        assert!(output.contains("COMMANDS:"));
2096        assert!(output.contains("EXIT CODES:"));
2097    }
2098
2099    // ===== Mandatory test 3: `truss help` shows top-level help =====
2100
2101    #[test]
2102    fn help_command_shows_top_level_help() {
2103        let mut stdin = Cursor::new(Vec::<u8>::new());
2104        let mut stdout = Vec::new();
2105        let mut stderr = Vec::new();
2106
2107        let exit_code = run_with_io(
2108            vec!["truss".to_string(), "help".to_string()],
2109            &mut stdin,
2110            &mut stdout,
2111            &mut stderr,
2112        );
2113
2114        assert_eq!(exit_code, 0);
2115        assert!(stderr.is_empty());
2116        let output = String::from_utf8(stdout).expect("utf8 stdout");
2117        assert!(output.contains("COMMANDS:"));
2118    }
2119
2120    // ===== Mandatory test 4: `truss help convert` shows convert help =====
2121
2122    #[test]
2123    fn help_convert_shows_convert_help() {
2124        let mut stdin = Cursor::new(Vec::<u8>::new());
2125        let mut stdout = Vec::new();
2126        let mut stderr = Vec::new();
2127
2128        let exit_code = run_with_io(
2129            vec![
2130                "truss".to_string(),
2131                "help".to_string(),
2132                "convert".to_string(),
2133            ],
2134            &mut stdin,
2135            &mut stdout,
2136            &mut stderr,
2137        );
2138
2139        assert_eq!(exit_code, 0);
2140        assert!(stderr.is_empty());
2141        let output = String::from_utf8(stdout).expect("utf8 stdout");
2142        assert!(output.contains("truss convert"));
2143        assert!(output.contains("--output"));
2144        assert!(output.contains("--width"));
2145        assert!(!output.contains("--bind")); // Should NOT contain serve options
2146    }
2147
2148    #[test]
2149    fn help_optimize_shows_optimize_help() {
2150        let mut stdin = Cursor::new(Vec::<u8>::new());
2151        let mut stdout = Vec::new();
2152        let mut stderr = Vec::new();
2153
2154        let exit_code = run_with_io(
2155            vec![
2156                "truss".to_string(),
2157                "help".to_string(),
2158                "optimize".to_string(),
2159            ],
2160            &mut stdin,
2161            &mut stdout,
2162            &mut stderr,
2163        );
2164
2165        assert_eq!(exit_code, 0);
2166        assert!(stderr.is_empty());
2167        let output = String::from_utf8(stdout).expect("utf8 stdout");
2168        assert!(output.contains("truss optimize"));
2169        assert!(output.contains("--mode"));
2170        assert!(output.contains("--target-quality"));
2171    }
2172
2173    // ===== Mandatory test 5: `truss convert --help` shows convert help =====
2174
2175    #[test]
2176    fn convert_dash_help_shows_convert_help() {
2177        let mut stdin = Cursor::new(Vec::<u8>::new());
2178        let mut stdout = Vec::new();
2179        let mut stderr = Vec::new();
2180
2181        let exit_code = run_with_io(
2182            vec![
2183                "truss".to_string(),
2184                "convert".to_string(),
2185                "--help".to_string(),
2186            ],
2187            &mut stdin,
2188            &mut stdout,
2189            &mut stderr,
2190        );
2191
2192        assert_eq!(exit_code, 0);
2193        assert!(stderr.is_empty());
2194        let output = String::from_utf8(stdout).expect("utf8 stdout");
2195        assert!(output.contains("truss convert"));
2196        assert!(output.contains("--output"));
2197    }
2198
2199    // ===== Mandatory test 6: `truss serve --help` shows serve help =====
2200
2201    #[test]
2202    fn serve_dash_help_shows_serve_help() {
2203        let mut stdin = Cursor::new(Vec::<u8>::new());
2204        let mut stdout = Vec::new();
2205        let mut stderr = Vec::new();
2206
2207        let exit_code = run_with_io(
2208            vec![
2209                "truss".to_string(),
2210                "serve".to_string(),
2211                "--help".to_string(),
2212            ],
2213            &mut stdin,
2214            &mut stdout,
2215            &mut stderr,
2216        );
2217
2218        assert_eq!(exit_code, 0);
2219        assert!(stderr.is_empty());
2220        let output = String::from_utf8(stdout).expect("utf8 stdout");
2221        assert!(output.contains("truss serve"));
2222        assert!(output.contains("--bind"));
2223        assert!(output.contains("--storage-root"));
2224        assert!(output.contains("ENVIRONMENT VARIABLES:"));
2225        assert!(!output.contains("--width")); // Should NOT contain convert options
2226    }
2227
2228    // ===== Mandatory test 7: `truss sign --help` shows sign help =====
2229
2230    #[test]
2231    fn sign_dash_help_shows_sign_help() {
2232        let mut stdin = Cursor::new(Vec::<u8>::new());
2233        let mut stdout = Vec::new();
2234        let mut stderr = Vec::new();
2235
2236        let exit_code = run_with_io(
2237            vec![
2238                "truss".to_string(),
2239                "sign".to_string(),
2240                "--help".to_string(),
2241            ],
2242            &mut stdin,
2243            &mut stdout,
2244            &mut stderr,
2245        );
2246
2247        assert_eq!(exit_code, 0);
2248        assert!(stderr.is_empty());
2249        let output = String::from_utf8(stdout).expect("utf8 stdout");
2250        assert!(output.contains("truss sign"));
2251        assert!(output.contains("--base-url"));
2252        assert!(output.contains("--key-id"));
2253        assert!(output.contains("--expires"));
2254    }
2255
2256    // ===== Mandatory test 8: convert missing --output shows usage and hint =====
2257
2258    #[test]
2259    fn convert_missing_output_shows_usage_and_hint() {
2260        let mut stdin = Cursor::new(Vec::<u8>::new());
2261        let mut stdout = Vec::new();
2262        let mut stderr = Vec::new();
2263
2264        let exit_code = run_with_io(
2265            vec![
2266                "truss".to_string(),
2267                "convert".to_string(),
2268                "input.png".to_string(),
2269            ],
2270            &mut stdin,
2271            &mut stdout,
2272            &mut stderr,
2273        );
2274
2275        assert_eq!(exit_code, 1);
2276        let output = String::from_utf8(stderr).expect("utf8 stderr");
2277        assert!(output.contains("error:"), "should contain error: {output}");
2278        assert!(output.contains("usage:"), "should contain usage: {output}");
2279        assert!(output.contains("hint:"), "should contain hint: {output}");
2280    }
2281
2282    // ===== Mandatory test 9: inspect missing input shows usage and hint =====
2283
2284    #[test]
2285    fn inspect_missing_input_shows_usage_and_hint() {
2286        let mut stdin = Cursor::new(Vec::<u8>::new());
2287        let mut stdout = Vec::new();
2288        let mut stderr = Vec::new();
2289
2290        let exit_code = run_with_io(
2291            vec!["truss".to_string(), "inspect".to_string()],
2292            &mut stdin,
2293            &mut stdout,
2294            &mut stderr,
2295        );
2296
2297        assert_eq!(exit_code, 1);
2298        let output = String::from_utf8(stderr).expect("utf8 stderr");
2299        assert!(output.contains("error:"), "should contain error: {output}");
2300        assert!(output.contains("usage:"), "should contain usage: {output}");
2301        assert!(output.contains("hint:"), "should contain hint: {output}");
2302    }
2303
2304    // ===== Mandatory test 10: sign missing args shows usage and hint =====
2305
2306    #[test]
2307    fn sign_missing_args_shows_usage_and_hint() {
2308        let mut stdin = Cursor::new(Vec::<u8>::new());
2309        let mut stdout = Vec::new();
2310        let mut stderr = Vec::new();
2311
2312        let exit_code = run_with_io(
2313            vec!["truss".to_string(), "sign".to_string()],
2314            &mut stdin,
2315            &mut stdout,
2316            &mut stderr,
2317        );
2318
2319        assert_eq!(exit_code, 1);
2320        let output = String::from_utf8(stderr).expect("utf8 stderr");
2321        assert!(output.contains("error:"), "should contain error: {output}");
2322        assert!(output.contains("usage:"), "should contain usage: {output}");
2323        assert!(output.contains("hint:"), "should contain hint: {output}");
2324    }
2325
2326    // ===== Mandatory test 11: -- allows -foo.png as input =====
2327
2328    #[test]
2329    fn double_dash_allows_leading_dash_input() {
2330        let result = parse_args(vec![
2331            "truss".to_string(),
2332            "convert".to_string(),
2333            "-o".to_string(),
2334            "out.jpg".to_string(),
2335            "--".to_string(),
2336            "-foo.png".to_string(),
2337        ]);
2338
2339        assert_eq!(
2340            result.unwrap(),
2341            Command::Convert(ConvertCommand {
2342                input: InputSource::Path(PathBuf::from("-foo.png")),
2343                output: OutputTarget::Path(PathBuf::from("out.jpg")),
2344                options: TransformOptions::default(),
2345                watermark_path: None,
2346                watermark_position: None,
2347                watermark_opacity: None,
2348                watermark_margin: None,
2349            })
2350        );
2351    }
2352
2353    // ===== Mandatory test 12: implicit convert with leading-dash output =====
2354    // Note: With clap, `-o -- -out.jpg` is not supported the same way.
2355    // Instead, use `-o=-out.jpg` or `--output=-out.jpg`.
2356
2357    // ===== Mandatory test 13: top-level serve flags still work =====
2358
2359    #[test]
2360    fn top_level_serve_flags_parse_correctly() {
2361        let command = parse_args(vec![
2362            "truss".to_string(),
2363            "--storage-root".to_string(),
2364            "fixtures".to_string(),
2365            "--public-base-url".to_string(),
2366            "https://assets.example.com".to_string(),
2367            "--allow-insecure-url-sources".to_string(),
2368        ])
2369        .expect("parse implicit serve");
2370
2371        assert_eq!(
2372            command,
2373            Command::Serve(ServeCommand {
2374                bind_addr: None,
2375                storage_root: Some(PathBuf::from("fixtures")),
2376                public_base_url: Some("https://assets.example.com".to_string()),
2377                signed_url_key_id: None,
2378                signed_url_secret: None,
2379                allow_insecure_url_sources: true,
2380            })
2381        );
2382    }
2383
2384    // ===== Mandatory test 14: implicit convert still works =====
2385
2386    #[test]
2387    fn implicit_convert_still_works() {
2388        let command = parse_args(vec![
2389            "truss".to_string(),
2390            "input.png".to_string(),
2391            "-o".to_string(),
2392            "output.jpg".to_string(),
2393            "--width".to_string(),
2394            "100".to_string(),
2395            "--fit".to_string(),
2396            "contain".to_string(),
2397        ])
2398        .expect("parse implicit convert");
2399
2400        assert_eq!(
2401            command,
2402            Command::Convert(ConvertCommand {
2403                input: InputSource::Path(PathBuf::from("input.png")),
2404                output: OutputTarget::Path(PathBuf::from("output.jpg")),
2405                options: TransformOptions {
2406                    width: Some(100),
2407                    fit: Some(Fit::Contain),
2408                    ..TransformOptions::default()
2409                },
2410                watermark_path: None,
2411                watermark_position: None,
2412                watermark_opacity: None,
2413                watermark_margin: None,
2414            })
2415        );
2416    }
2417
2418    // ===== Mandatory test 15: exit codes are consistent =====
2419
2420    #[test]
2421    fn exit_code_help_is_zero() {
2422        let mut stdin = Cursor::new(Vec::<u8>::new());
2423        let mut stdout = Vec::new();
2424        let mut stderr = Vec::new();
2425
2426        let code = run_with_io(
2427            vec!["truss".to_string(), "--help".to_string()],
2428            &mut stdin,
2429            &mut stdout,
2430            &mut stderr,
2431        );
2432        assert_eq!(code, 0);
2433    }
2434
2435    #[test]
2436    fn exit_code_usage_error_is_one() {
2437        let mut stdin = Cursor::new(Vec::<u8>::new());
2438        let mut stdout = Vec::new();
2439        let mut stderr = Vec::new();
2440
2441        let code = run_with_io(
2442            vec![
2443                "truss".to_string(),
2444                "convert".to_string(),
2445                "input.png".to_string(),
2446            ],
2447            &mut stdin,
2448            &mut stdout,
2449            &mut stderr,
2450        );
2451        assert_eq!(code, 1);
2452    }
2453
2454    #[test]
2455    fn exit_code_io_error() {
2456        let mut stdin = Cursor::new(Vec::<u8>::new());
2457        let mut stdout = Vec::new();
2458        let mut stderr = Vec::new();
2459
2460        let code = run_with_io(
2461            vec![
2462                "truss".to_string(),
2463                "inspect".to_string(),
2464                "missing-file.png".to_string(),
2465            ],
2466            &mut stdin,
2467            &mut stdout,
2468            &mut stderr,
2469        );
2470        assert_eq!(code, 2);
2471    }
2472
2473    #[test]
2474    fn exit_code_input_error() {
2475        let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
2476        let mut stdout = Vec::new();
2477        let mut stderr = Vec::new();
2478
2479        let code = run_with_io(
2480            vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
2481            &mut stdin,
2482            &mut stdout,
2483            &mut stderr,
2484        );
2485        assert_eq!(code, 3);
2486    }
2487
2488    /// The class the CLI names alongside each exit code, and the anchor
2489    /// `docs/problems.md` gives it. The exit codes are the CLI's column of that page's
2490    /// table, so this is what keeps the page honest: a class that changes exit code, or an
2491    /// exit code that changes class, fails here.
2492    #[test]
2493    fn map_transform_error_matches_the_class_table() {
2494        const PROBLEM_DOCS: &str = include_str!("../../../docs/problems.md");
2495        // A Windows checkout has CRLF line endings, so the anchors are matched against
2496        // the text with the carriage returns taken out.
2497        let problem_docs = PROBLEM_DOCS.replace('\r', "");
2498        let cases: [(crate::TransformError, &str, u8); 8] = [
2499            (
2500                crate::TransformError::InvalidOptions("x".into()),
2501                "invalid-options",
2502                EXIT_USAGE,
2503            ),
2504            (
2505                crate::TransformError::InvalidInput("x".into()),
2506                "invalid-input",
2507                EXIT_INPUT,
2508            ),
2509            (
2510                crate::TransformError::UnsupportedInputMediaType("x".into()),
2511                "unsupported-input-media-type",
2512                EXIT_INPUT,
2513            ),
2514            (
2515                crate::TransformError::DecodeFailed("x".into()),
2516                "decode-failed",
2517                EXIT_TRANSFORM,
2518            ),
2519            (
2520                crate::TransformError::EncodeFailed("x".into()),
2521                "encode-failed",
2522                EXIT_TRANSFORM,
2523            ),
2524            (
2525                crate::TransformError::CapabilityMissing("x".into()),
2526                "capability-missing",
2527                EXIT_TRANSFORM,
2528            ),
2529            (
2530                crate::TransformError::LimitExceeded("x".into()),
2531                "limit-exceeded",
2532                EXIT_TRANSFORM,
2533            ),
2534            (
2535                crate::TransformError::UnsupportedOutputMediaType(MediaType::Gif),
2536                "unsupported-output-media-type",
2537                EXIT_TRANSFORM,
2538            ),
2539        ];
2540
2541        for (error, slug, exit_code) in cases {
2542            let mapped = super::map_transform_error(error.clone());
2543            assert_eq!(mapped.class.slug(), slug, "{error:?}");
2544            assert_eq!(mapped.exit_code, exit_code, "{error:?}");
2545            assert!(
2546                problem_docs.contains(&format!("### {slug}\n")),
2547                "docs/problems.md should document the {slug} class"
2548            );
2549        }
2550    }
2551
2552    /// stderr carries the class in parentheses after the message, which is how a caller
2553    /// reads the same classification the server puts in `type` and the browser in `kind`.
2554    #[test]
2555    fn stderr_names_the_class_after_the_message() {
2556        let mut stderr = Vec::new();
2557        let code = super::write_error(
2558            &mut stderr,
2559            super::map_transform_error(crate::TransformError::LimitExceeded(
2560                "output image would have 400000000 pixels, limit is 67108864".to_string(),
2561            )),
2562        );
2563
2564        assert_eq!(code, EXIT_TRANSFORM);
2565        assert_eq!(
2566            String::from_utf8(stderr).expect("utf-8 stderr"),
2567            "error: output image would have 400000000 pixels, limit is 67108864 (limit-exceeded)\n"
2568        );
2569    }
2570
2571    /// A usage fault names the request, not the transform, and keeps its usage and hint
2572    /// lines below the classified first line.
2573    #[test]
2574    fn stderr_names_the_class_on_a_usage_error() {
2575        let mut stderr = Vec::new();
2576        let code = super::write_error(&mut stderr, super::sign_error("'sign' requires --key-id"));
2577
2578        assert_eq!(code, EXIT_USAGE);
2579        let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2580        assert!(
2581            rendered.starts_with("error: 'sign' requires --key-id (invalid-request)\n"),
2582            "{rendered}"
2583        );
2584        assert!(rendered.ends_with("hint: run 'truss sign --help' for sign options\n"));
2585    }
2586
2587    /// A source that is not there is `not-found`, the class the server gives the same miss,
2588    /// while the exit code stays 2.
2589    #[test]
2590    fn missing_input_reports_the_not_found_class() {
2591        let mut stdin = Cursor::new(Vec::<u8>::new());
2592        let mut stdout = Vec::new();
2593        let mut stderr = Vec::new();
2594
2595        let code = run_with_io(
2596            vec![
2597                "truss".to_string(),
2598                "inspect".to_string(),
2599                "missing-file.png".to_string(),
2600            ],
2601            &mut stdin,
2602            &mut stdout,
2603            &mut stderr,
2604        );
2605
2606        assert_eq!(code, 2);
2607        let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2608        assert!(rendered.contains("(not-found)"), "{rendered}");
2609    }
2610
2611    /// A decode failure is exit 4 wherever it is raised. The sniff that runs before the
2612    /// transform used to report it as an input error (3), so one truncated file was a
2613    /// transform error to `convert` and an input error to `inspect`.
2614    #[test]
2615    fn a_decode_failure_from_the_sniff_is_a_transform_error() {
2616        // A PNG signature with nothing after it: recognised as PNG, too short to decode.
2617        let truncated = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
2618        for command in ["inspect", "convert"] {
2619            let mut stdin = Cursor::new(truncated.clone());
2620            let mut stdout = Vec::new();
2621            let mut stderr = Vec::new();
2622
2623            let mut args = vec!["truss".to_string(), command.to_string(), "-".to_string()];
2624            if command == "convert" {
2625                args.push("--output".to_string());
2626                args.push("-".to_string());
2627            }
2628            let code = run_with_io(args, &mut stdin, &mut stdout, &mut stderr);
2629
2630            let rendered = String::from_utf8(stderr).expect("utf-8 stderr");
2631            assert_eq!(code, EXIT_TRANSFORM, "{command}: {rendered}");
2632            assert!(
2633                rendered.contains("(decode-failed)"),
2634                "{command}: {rendered}"
2635            );
2636        }
2637    }
2638
2639    // ===== Additional test: unknown subcommand =====
2640
2641    #[test]
2642    fn unknown_subcommand_exits_with_usage_error() {
2643        let mut stdin = Cursor::new(Vec::<u8>::new());
2644        let mut stdout = Vec::new();
2645        let mut stderr = Vec::new();
2646
2647        let exit_code = run_with_io(
2648            vec!["truss".to_string(), "converrt".to_string()],
2649            &mut stdin,
2650            &mut stdout,
2651            &mut stderr,
2652        );
2653
2654        assert_eq!(exit_code, 1);
2655        let output = String::from_utf8(stderr).expect("utf8 stderr");
2656        assert!(output.contains("error:"), "should contain error: {output}");
2657    }
2658
2659    // ===== Additional test: inspect --help =====
2660
2661    #[test]
2662    fn inspect_dash_help_shows_inspect_help() {
2663        let mut stdin = Cursor::new(Vec::<u8>::new());
2664        let mut stdout = Vec::new();
2665        let mut stderr = Vec::new();
2666
2667        let exit_code = run_with_io(
2668            vec![
2669                "truss".to_string(),
2670                "inspect".to_string(),
2671                "--help".to_string(),
2672            ],
2673            &mut stdin,
2674            &mut stdout,
2675            &mut stderr,
2676        );
2677
2678        assert_eq!(exit_code, 0);
2679        let output = String::from_utf8(stdout).expect("utf8 stdout");
2680        assert!(output.contains("truss inspect"));
2681        assert!(output.contains("--url"));
2682    }
2683
2684    // ===== Additional test: help inspect =====
2685
2686    #[test]
2687    fn help_inspect_shows_inspect_help() {
2688        let result = parse_args(vec![
2689            "truss".to_string(),
2690            "help".to_string(),
2691            "inspect".to_string(),
2692        ]);
2693        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Inspect));
2694    }
2695
2696    // ===== Additional test: help serve =====
2697
2698    /// Every environment variable in the serve help sits in one column.
2699    ///
2700    /// The rows are written in blocks behind feature gates, and a block opened with a
2701    /// backslash line continuation loses the indentation of its first line along with
2702    /// the newline, so the first name in each gated block printed flush left in exactly
2703    /// the builds the release ships. Asserting the shape of the whole section rather
2704    /// than four names catches a block added later.
2705    #[test]
2706    fn help_serve_keeps_every_environment_variable_in_one_column() {
2707        let help = super::help_serve();
2708        let misaligned: Vec<&str> = help
2709            .lines()
2710            .skip_while(|line| !line.starts_with("ENVIRONMENT VARIABLES:"))
2711            .filter(|line| {
2712                let trimmed = line.trim_start();
2713                trimmed.starts_with("TRUSS_")
2714                    || trimmed.starts_with("AWS_")
2715                    || trimmed.starts_with("AZURE_")
2716                    || trimmed.starts_with("GOOGLE_")
2717            })
2718            .filter(|line| !line.starts_with("  ") || line.starts_with("   "))
2719            .collect();
2720
2721        assert!(
2722            misaligned.is_empty(),
2723            "every environment variable row is indented two spaces, found: {misaligned:#?}"
2724        );
2725    }
2726
2727    /// The serve help names every setting that is not behind a storage feature, and names
2728    /// nothing that `docs/configuration.md` does not document.
2729    ///
2730    /// The section used to be a hand-written block extended when someone remembered, and it
2731    /// had drifted to 20 of the 45 documented variables with no rule separating the two
2732    /// groups: `TRUSS_CACHE_ROOT` was in it and `TRUSS_CACHE_MAX_BYTES` was not, and neither
2733    /// of the two tokens that keep `/metrics` and `/health` from being public was. Reading
2734    /// the reference rather than repeating the list is what keeps the comparison honest;
2735    /// the backend sections are left out because those rows are compiled in per feature and
2736    /// this build may have none of them.
2737    #[test]
2738    fn the_serve_help_names_every_documented_setting_that_is_not_behind_a_feature() {
2739        let reference = include_str!("../../../docs/configuration.md");
2740        let help = super::help_serve();
2741
2742        let mut documented: Vec<&str> = Vec::new();
2743        let mut in_backend_section = false;
2744        for line in reference.lines() {
2745            if let Some(heading) = line.strip_prefix("## ") {
2746                in_backend_section = matches!(heading, "S3" | "GCS" | "Azure Blob Storage");
2747            }
2748            if in_backend_section {
2749                continue;
2750            }
2751            let Some(rest) = line.strip_prefix("| `TRUSS_") else {
2752                continue;
2753            };
2754            let Some(end) = rest.find('`') else { continue };
2755            let name = &line[3..3 + "TRUSS_".len() + end];
2756            if !documented.contains(&name) {
2757                documented.push(name);
2758            }
2759        }
2760        assert!(
2761            documented.len() > 30,
2762            "the reference was read wrong: {documented:?}"
2763        );
2764
2765        let missing: Vec<&&str> = documented
2766            .iter()
2767            .filter(|name| !help.contains(*name))
2768            .collect();
2769        assert!(
2770            missing.is_empty(),
2771            "documented settings the serve help does not name: {missing:?}"
2772        );
2773
2774        let undocumented: Vec<&str> = help
2775            .lines()
2776            .filter_map(|line| line.split_whitespace().next())
2777            // The NOTE at the end of the section shows a variable being assigned, which is
2778            // an example rather than a row.
2779            .map(|word| word.split('=').next().unwrap_or(word))
2780            .filter(|word| word.starts_with("TRUSS_"))
2781            .filter(|word| !reference.contains(&format!("`{word}`")))
2782            .collect();
2783        assert!(
2784            undocumented.is_empty(),
2785            "settings the serve help names and docs/configuration.md does not: {undocumented:?}"
2786        );
2787    }
2788
2789    #[test]
2790    fn help_serve_shows_serve_help() {
2791        let result = parse_args(vec![
2792            "truss".to_string(),
2793            "help".to_string(),
2794            "serve".to_string(),
2795        ]);
2796        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Serve));
2797    }
2798
2799    // ===== Additional test: help validate =====
2800
2801    #[test]
2802    fn help_validate_shows_validate_help() {
2803        let result = parse_args(vec![
2804            "truss".to_string(),
2805            "help".to_string(),
2806            "validate".to_string(),
2807        ]);
2808        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
2809    }
2810
2811    #[test]
2812    fn parse_args_validate() {
2813        let result =
2814            parse_args(vec!["truss".to_string(), "validate".to_string()]).expect("parse validate");
2815        assert_eq!(result, Command::Validate);
2816    }
2817
2818    #[test]
2819    fn validate_help_flag() {
2820        let result = parse_args(vec![
2821            "truss".to_string(),
2822            "validate".to_string(),
2823            "--help".to_string(),
2824        ]);
2825        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Validate));
2826    }
2827
2828    #[test]
2829    #[serial]
2830    fn validate_invalid_config() {
2831        // SAFETY: test-only, single-threaded access to this env var.
2832        unsafe { env::set_var("TRUSS_MAX_CONCURRENT_TRANSFORMS", "invalid") };
2833        let mut stdout = Vec::new();
2834        let result = super::serve::execute_validate(&mut stdout);
2835        unsafe { env::remove_var("TRUSS_MAX_CONCURRENT_TRANSFORMS") };
2836        assert!(result.is_err());
2837    }
2838
2839    #[test]
2840    #[serial]
2841    fn validate_valid_config() {
2842        let dir = tempfile::tempdir().expect("create temp dir");
2843        let mut stdout = Vec::new();
2844        // SAFETY: test-only, single-threaded access to this env var.
2845        unsafe { env::set_var("TRUSS_STORAGE_ROOT", dir.path().to_str().unwrap()) };
2846        let result = super::serve::execute_validate(&mut stdout);
2847        unsafe { env::remove_var("TRUSS_STORAGE_ROOT") };
2848        assert!(result.is_ok());
2849        let output = String::from_utf8(stdout).expect("valid utf-8");
2850        assert!(output.contains("configuration is valid"));
2851        assert!(output.contains("storage root:"));
2852    }
2853
2854    // ===== Additional test: help sign =====
2855
2856    #[test]
2857    fn help_sign_shows_sign_help() {
2858        let result = parse_args(vec![
2859            "truss".to_string(),
2860            "help".to_string(),
2861            "sign".to_string(),
2862        ]);
2863        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Sign));
2864    }
2865
2866    // ===== Additional test: -h works as --help =====
2867
2868    #[test]
2869    fn dash_h_shows_top_level_help() {
2870        let result = parse_args(vec!["truss".to_string(), "-h".to_string()]);
2871        assert_eq!(result.unwrap(), Command::Help(HelpTopic::TopLevel));
2872    }
2873
2874    // ===== Existing test updates =====
2875
2876    #[test]
2877    fn parse_args_supports_serve_bind() {
2878        let command = parse_args(vec![
2879            "truss".to_string(),
2880            "serve".to_string(),
2881            "--bind".to_string(),
2882            "127.0.0.1:9000".to_string(),
2883        ])
2884        .expect("parse serve bind");
2885
2886        assert_eq!(
2887            command,
2888            Command::Serve(ServeCommand {
2889                bind_addr: Some("127.0.0.1:9000".to_string()),
2890                storage_root: None,
2891                public_base_url: None,
2892                signed_url_key_id: None,
2893                signed_url_secret: None,
2894                allow_insecure_url_sources: false,
2895            })
2896        );
2897    }
2898
2899    #[test]
2900    fn parse_args_supports_serve_runtime_options() {
2901        let command = parse_args(vec![
2902            "truss".to_string(),
2903            "serve".to_string(),
2904            "--storage-root".to_string(),
2905            "fixtures".to_string(),
2906            "--public-base-url".to_string(),
2907            "https://assets.example.com".to_string(),
2908            "--signed-url-key-id".to_string(),
2909            "public-dev".to_string(),
2910            "--signed-url-secret".to_string(),
2911            "secret-value".to_string(),
2912            "--allow-insecure-url-sources".to_string(),
2913        ])
2914        .expect("parse serve runtime options");
2915
2916        assert_eq!(
2917            command,
2918            Command::Serve(ServeCommand {
2919                bind_addr: None,
2920                storage_root: Some(PathBuf::from("fixtures")),
2921                public_base_url: Some("https://assets.example.com".to_string()),
2922                signed_url_key_id: Some("public-dev".to_string()),
2923                signed_url_secret: Some("secret-value".to_string()),
2924                allow_insecure_url_sources: true,
2925            })
2926        );
2927    }
2928
2929    /// A crop origin written with a space reaches `parse_crop`, so the caller is told which
2930    /// field is wrong rather than being sent to `--` by clap. The two spellings answer the
2931    /// same way, which is the property `allow_hyphen_values` buys.
2932    #[test]
2933    fn a_negative_crop_origin_is_answered_by_the_crop_validator_however_it_is_written() {
2934        let spaced = parse_args(vec![
2935            "truss".to_string(),
2936            "convert".to_string(),
2937            "in.png".to_string(),
2938            "-o".to_string(),
2939            "out.png".to_string(),
2940            "--crop".to_string(),
2941            "-1,0,2,2".to_string(),
2942        ])
2943        .expect_err("a negative crop origin is refused");
2944
2945        let assigned = parse_args(vec![
2946            "truss".to_string(),
2947            "convert".to_string(),
2948            "in.png".to_string(),
2949            "-o".to_string(),
2950            "out.png".to_string(),
2951            "--crop=-1,0,2,2".to_string(),
2952        ])
2953        .expect_err("a negative crop origin is refused");
2954
2955        assert_eq!(spaced.message, assigned.message);
2956        assert!(
2957            spaced
2958                .message
2959                .contains("crop x must be a non-negative integer, got '-1'"),
2960            "message: {}",
2961            spaced.message,
2962        );
2963    }
2964
2965    /// The same for `sign`, which carries the same flag.
2966    #[test]
2967    fn sign_answers_a_negative_crop_origin_with_the_crop_validator() {
2968        let error = parse_args(vec![
2969            "truss".to_string(),
2970            "sign".to_string(),
2971            "--path".to_string(),
2972            "a.png".to_string(),
2973            "--crop".to_string(),
2974            "-1,0,2,2".to_string(),
2975        ])
2976        .expect_err("a negative crop origin is refused");
2977
2978        assert!(
2979            error
2980                .message
2981                .contains("crop x must be a non-negative integer, got '-1'"),
2982            "message: {}",
2983            error.message,
2984        );
2985    }
2986
2987    /// A sigma outside the range is the transform's judgement, not the parser's, so the CLI
2988    /// reports the class every other adapter reports for the same number. A value that is
2989    /// not a number at all stays the parser's, as it is for every other numeric flag.
2990    #[test]
2991    fn an_out_of_range_sigma_is_invalid_options_and_a_non_number_is_invalid_request() {
2992        for flag in ["--blur", "--sharpen"] {
2993            for value in ["0", "100.1"] {
2994                let command = parse_args(vec![
2995                    "truss".to_string(),
2996                    "convert".to_string(),
2997                    "in.png".to_string(),
2998                    "-o".to_string(),
2999                    "out.png".to_string(),
3000                    flag.to_string(),
3001                    value.to_string(),
3002                ])
3003                .unwrap_or_else(|error| {
3004                    panic!(
3005                        "{flag} {value} should reach the transform: {}",
3006                        error.message
3007                    )
3008                });
3009                let options = match command {
3010                    Command::Convert(convert) => convert.options,
3011                    _ => panic!("expected convert command"),
3012                };
3013                let error = options
3014                    .normalize(crate::MediaType::Png)
3015                    .expect_err("a sigma outside the range is refused");
3016                assert_eq!(
3017                    error.class(),
3018                    crate::core::error_class::ErrorClass::InvalidOptions,
3019                    "{flag} {value}"
3020                );
3021            }
3022
3023            let error = parse_args(vec![
3024                "truss".to_string(),
3025                "convert".to_string(),
3026                "in.png".to_string(),
3027                "-o".to_string(),
3028                "out.png".to_string(),
3029                flag.to_string(),
3030                "abc".to_string(),
3031            ])
3032            .expect_err("a sigma that is not a number is refused");
3033            assert_eq!(
3034                error.class,
3035                crate::core::error_class::ErrorClass::InvalidRequest,
3036                "{flag}"
3037            );
3038        }
3039    }
3040
3041    #[test]
3042    fn parse_args_rejects_partial_signed_url_credentials() {
3043        let error = parse_args(vec![
3044            "truss".to_string(),
3045            "serve".to_string(),
3046            "--signed-url-key-id".to_string(),
3047            "public-dev".to_string(),
3048        ])
3049        .expect("parse serve args first");
3050
3051        let error = match error {
3052            Command::Serve(command) => {
3053                resolve_server_config(command).expect_err("partial credentials should fail")
3054            }
3055            _ => panic!("expected serve command"),
3056        };
3057
3058        assert_eq!(error.exit_code, 1);
3059        assert!(
3060            error
3061                .message
3062                .contains("--signed-url-key-id and --signed-url-secret must be provided together")
3063        );
3064    }
3065
3066    #[test]
3067    fn parse_args_rejects_invalid_public_base_url() {
3068        let error = parse_args(vec![
3069            "truss".to_string(),
3070            "serve".to_string(),
3071            "--public-base-url".to_string(),
3072            "ftp://assets.example.com".to_string(),
3073        ])
3074        .expect_err("invalid public base URL should fail");
3075
3076        assert_eq!(error.exit_code, 1);
3077        assert!(
3078            error
3079                .message
3080                .contains("requires an http:// or https:// URL"),
3081            "message: {}",
3082            error.message,
3083        );
3084    }
3085
3086    #[test]
3087    #[serial]
3088    fn resolve_server_config_applies_serve_overrides() {
3089        let storage_root = temp_dir("serve-config");
3090        let expected_storage_root = storage_root.canonicalize().expect("canonicalize temp dir");
3091        let config = resolve_server_config(ServeCommand {
3092            bind_addr: Some("127.0.0.1:0".to_string()),
3093            storage_root: Some(storage_root.clone()),
3094            public_base_url: Some("https://assets.example.com".to_string()),
3095            signed_url_key_id: Some("public-dev".to_string()),
3096            signed_url_secret: Some("secret-value".to_string()),
3097            allow_insecure_url_sources: true,
3098        })
3099        .expect("resolve server config");
3100
3101        let _ = fs::remove_dir_all(storage_root);
3102
3103        assert_eq!(config.storage_root, expected_storage_root);
3104        assert_eq!(
3105            config.public_base_url.as_deref(),
3106            Some("https://assets.example.com")
3107        );
3108        assert_eq!(config.signed_url_key_id.as_deref(), Some("public-dev"));
3109        assert_eq!(config.signed_url_secret.as_deref(), Some("secret-value"));
3110        assert!(config.allow_insecure_url_sources);
3111    }
3112
3113    #[test]
3114    fn parse_args_supports_inspect_path() {
3115        let command = parse_args(vec![
3116            "truss".to_string(),
3117            "inspect".to_string(),
3118            "input.png".to_string(),
3119        ])
3120        .expect("parse inspect path");
3121
3122        assert_eq!(
3123            command,
3124            Command::Inspect(super::InspectCommand {
3125                input: InputSource::Path(PathBuf::from("input.png"))
3126            })
3127        );
3128    }
3129
3130    #[test]
3131    fn parse_args_supports_inspect_url() {
3132        let command = parse_args(vec![
3133            "truss".to_string(),
3134            "inspect".to_string(),
3135            "--url".to_string(),
3136            "http://example.com/image.png".to_string(),
3137        ])
3138        .expect("parse inspect url");
3139
3140        assert_eq!(
3141            command,
3142            Command::Inspect(super::InspectCommand {
3143                input: InputSource::Url("http://example.com/image.png".to_string())
3144            })
3145        );
3146    }
3147
3148    #[test]
3149    fn parse_args_supports_convert_path_and_output() {
3150        let command = parse_args(vec![
3151            "truss".to_string(),
3152            "convert".to_string(),
3153            "input.png".to_string(),
3154            "-o".to_string(),
3155            "output.jpg".to_string(),
3156            "--width".to_string(),
3157            "100".to_string(),
3158            "--fit".to_string(),
3159            "contain".to_string(),
3160        ])
3161        .expect("parse convert");
3162
3163        assert_eq!(
3164            command,
3165            Command::Convert(ConvertCommand {
3166                input: InputSource::Path(PathBuf::from("input.png")),
3167                output: OutputTarget::Path(PathBuf::from("output.jpg")),
3168                options: TransformOptions {
3169                    width: Some(100),
3170                    fit: Some(Fit::Contain),
3171                    ..TransformOptions::default()
3172                },
3173                watermark_path: None,
3174                watermark_position: None,
3175                watermark_opacity: None,
3176                watermark_margin: None,
3177            })
3178        );
3179    }
3180
3181    #[test]
3182    fn parse_args_supports_optimize_subcommand() {
3183        let command = parse_args(vec![
3184            "truss".to_string(),
3185            "optimize".to_string(),
3186            "input.png".to_string(),
3187            "-o".to_string(),
3188            "output.png".to_string(),
3189            "--mode".to_string(),
3190            "lossless".to_string(),
3191        ])
3192        .expect("parse optimize");
3193
3194        assert_eq!(
3195            command,
3196            Command::Optimize(ConvertCommand {
3197                input: InputSource::Path(PathBuf::from("input.png")),
3198                output: OutputTarget::Path(PathBuf::from("output.png")),
3199                options: TransformOptions {
3200                    optimize: OptimizeMode::Lossless,
3201                    ..TransformOptions::default()
3202                },
3203                watermark_path: None,
3204                watermark_position: None,
3205                watermark_opacity: None,
3206                watermark_margin: None,
3207            })
3208        );
3209    }
3210
3211    /// `truss optimize --mode none` re-encodes without optimizing, so it made files larger
3212    /// on the one subcommand that promises the opposite, and it was the only mode that
3213    /// reached an output format the others refuse. It is `truss convert` under a name that
3214    /// says the reverse, so it is not a mode of this command.
3215    #[test]
3216    fn optimize_refuses_a_mode_that_does_not_optimize() {
3217        let message =
3218            parse_optimizing_mode("none").expect_err("`none` is not an optimization mode");
3219        assert!(
3220            message.contains("truss convert"),
3221            "the refusal should name the command that does a plain re-encode: {message}"
3222        );
3223
3224        for mode in ["auto", "lossless", "lossy"] {
3225            assert!(
3226                parse_optimizing_mode(mode).is_ok(),
3227                "{mode} is an optimization mode"
3228            );
3229        }
3230
3231        // The value keeps its meaning everywhere else, including on `truss convert`.
3232        assert_eq!(parse_optimize_mode("none"), Ok(OptimizeMode::None));
3233    }
3234
3235    #[test]
3236    fn parse_args_optimize_defaults_to_auto_mode() {
3237        let command = parse_args(vec![
3238            "truss".to_string(),
3239            "optimize".to_string(),
3240            "input.png".to_string(),
3241            "-o".to_string(),
3242            "output.webp".to_string(),
3243        ])
3244        .expect("parse optimize");
3245
3246        assert_eq!(
3247            command,
3248            Command::Optimize(ConvertCommand {
3249                input: InputSource::Path(PathBuf::from("input.png")),
3250                output: OutputTarget::Path(PathBuf::from("output.webp")),
3251                options: TransformOptions {
3252                    optimize: OptimizeMode::Auto,
3253                    ..TransformOptions::default()
3254                },
3255                watermark_path: None,
3256                watermark_position: None,
3257                watermark_opacity: None,
3258                watermark_margin: None,
3259            })
3260        );
3261    }
3262
3263    #[test]
3264    fn parse_args_rejects_non_optimizable_optimize_format() {
3265        let error = parse_args(vec![
3266            "truss".to_string(),
3267            "optimize".to_string(),
3268            "input.png".to_string(),
3269            "-o".to_string(),
3270            "output.svg".to_string(),
3271            "--format".to_string(),
3272            "svg".to_string(),
3273        ])
3274        .expect_err("svg optimize output should be rejected");
3275
3276        assert!(
3277            error
3278                .message
3279                .contains("optimization is not supported for svg output")
3280        );
3281    }
3282
3283    #[test]
3284    fn parse_args_supports_convert_url_and_output() {
3285        let command = parse_args(vec![
3286            "truss".to_string(),
3287            "convert".to_string(),
3288            "--url".to_string(),
3289            "http://example.com/image.png".to_string(),
3290            "-o".to_string(),
3291            "output.jpg".to_string(),
3292        ])
3293        .expect("parse convert url");
3294
3295        assert_eq!(
3296            command,
3297            Command::Convert(ConvertCommand {
3298                input: InputSource::Url("http://example.com/image.png".to_string()),
3299                output: OutputTarget::Path(PathBuf::from("output.jpg")),
3300                options: TransformOptions::default(),
3301                watermark_path: None,
3302                watermark_position: None,
3303                watermark_opacity: None,
3304                watermark_margin: None,
3305            })
3306        );
3307    }
3308
3309    #[test]
3310    fn parse_args_supports_sign_for_path_sources() {
3311        let command = parse_args(vec![
3312            "truss".to_string(),
3313            "sign".to_string(),
3314            "--base-url".to_string(),
3315            "https://cdn.example.com".to_string(),
3316            "--path".to_string(),
3317            "/image.png".to_string(),
3318            "--key-id".to_string(),
3319            "public-dev".to_string(),
3320            "--secret".to_string(),
3321            "secret-value".to_string(),
3322            "--expires".to_string(),
3323            "4102444800".to_string(),
3324            "--format".to_string(),
3325            "jpeg".to_string(),
3326        ])
3327        .expect("parse sign path");
3328
3329        assert_eq!(
3330            command,
3331            Command::Sign(SignCommand {
3332                base_url: "https://cdn.example.com".to_string(),
3333                method: super::SignedMethod::Get,
3334                source: SignedUrlSource::Path {
3335                    path: "/image.png".to_string(),
3336                    version: None
3337                },
3338                key_id: "public-dev".to_string(),
3339                secret: "secret-value".to_string(),
3340                expires: 4_102_444_800,
3341                options: TransformOptions {
3342                    format: Some(MediaType::Jpeg),
3343                    ..TransformOptions::default()
3344                },
3345                watermark_url: None,
3346                watermark_position: None,
3347                watermark_opacity: None,
3348                watermark_margin: None,
3349                preset: None,
3350            })
3351        );
3352    }
3353
3354    #[test]
3355    fn parse_args_supports_sign_for_url_sources() {
3356        let command = parse_args(vec![
3357            "truss".to_string(),
3358            "sign".to_string(),
3359            "--base-url".to_string(),
3360            "https://cdn.example.com".to_string(),
3361            "--url".to_string(),
3362            "https://origin.example.com/image.png".to_string(),
3363            "--version".to_string(),
3364            "v2".to_string(),
3365            "--key-id".to_string(),
3366            "public-dev".to_string(),
3367            "--secret".to_string(),
3368            "secret-value".to_string(),
3369            "--expires".to_string(),
3370            "4102444800".to_string(),
3371            "--width".to_string(),
3372            "120".to_string(),
3373        ])
3374        .expect("parse sign url");
3375
3376        assert_eq!(
3377            command,
3378            Command::Sign(SignCommand {
3379                base_url: "https://cdn.example.com".to_string(),
3380                method: super::SignedMethod::Get,
3381                source: SignedUrlSource::Url {
3382                    url: "https://origin.example.com/image.png".to_string(),
3383                    version: Some("v2".to_string())
3384                },
3385                key_id: "public-dev".to_string(),
3386                secret: "secret-value".to_string(),
3387                expires: 4_102_444_800,
3388                options: TransformOptions {
3389                    width: Some(120),
3390                    ..TransformOptions::default()
3391                },
3392                watermark_url: None,
3393                watermark_position: None,
3394                watermark_opacity: None,
3395                watermark_margin: None,
3396                preset: None,
3397            })
3398        );
3399    }
3400
3401    #[test]
3402    fn parse_args_supports_sign_with_preset() {
3403        let command = parse_args(vec![
3404            "truss".to_string(),
3405            "sign".to_string(),
3406            "--base-url".to_string(),
3407            "https://cdn.example.com".to_string(),
3408            "--path".to_string(),
3409            "/hero.jpg".to_string(),
3410            "--key-id".to_string(),
3411            "mykey".to_string(),
3412            "--secret".to_string(),
3413            "s3cret".to_string(),
3414            "--expires".to_string(),
3415            "1700000000".to_string(),
3416            "--preset".to_string(),
3417            "thumbnail".to_string(),
3418        ])
3419        .expect("parse sign with preset");
3420
3421        match command {
3422            Command::Sign(s) => assert_eq!(s.preset.as_deref(), Some("thumbnail")),
3423            _ => panic!("expected Sign command"),
3424        }
3425    }
3426
3427    #[test]
3428    fn parse_args_rejects_missing_convert_output() {
3429        let error = parse_args(vec![
3430            "truss".to_string(),
3431            "convert".to_string(),
3432            "input.png".to_string(),
3433        ])
3434        .expect_err("missing output should fail");
3435
3436        assert_eq!(error.exit_code, 1);
3437        assert!(error.message.contains("requires -o"));
3438    }
3439
3440    #[test]
3441    fn parse_args_supports_inspect_https_url() {
3442        let command = parse_args(vec![
3443            "truss".to_string(),
3444            "inspect".to_string(),
3445            "--url".to_string(),
3446            "https://example.com/image.png".to_string(),
3447        ])
3448        .expect("inspect https url should parse");
3449
3450        assert_eq!(
3451            command,
3452            Command::Inspect(super::InspectCommand {
3453                input: InputSource::Url("https://example.com/image.png".to_string())
3454            })
3455        );
3456    }
3457
3458    #[test]
3459    fn parse_args_rejects_invalid_convert_url_scheme() {
3460        let error = parse_args(vec![
3461            "truss".to_string(),
3462            "convert".to_string(),
3463            "--url".to_string(),
3464            "ftp://example.com/image.png".to_string(),
3465            "-o".to_string(),
3466            "out.png".to_string(),
3467        ])
3468        .expect_err("convert invalid scheme should fail");
3469
3470        assert_eq!(error.exit_code, 1);
3471        assert!(
3472            error
3473                .message
3474                .contains("requires an http:// or https:// URL")
3475        );
3476    }
3477
3478    #[test]
3479    fn run_with_io_converts_without_explicit_subcommand() {
3480        let input_path = temp_file_path("implicit-convert-input");
3481        let output_path = temp_file_path("implicit-convert-output").with_extension("jpg");
3482        fs::write(&input_path, png_bytes()).expect("write input file");
3483
3484        let mut stdin = Cursor::new(Vec::<u8>::new());
3485        let mut stdout = Vec::new();
3486        let mut stderr = Vec::new();
3487
3488        let exit_code = run_with_io(
3489            vec![
3490                "truss".to_string(),
3491                input_path.display().to_string(),
3492                "-o".to_string(),
3493                output_path.display().to_string(),
3494            ],
3495            &mut stdin,
3496            &mut stdout,
3497            &mut stderr,
3498        );
3499
3500        let output_bytes = fs::read(&output_path).expect("read output file");
3501        let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3502
3503        let _ = fs::remove_file(&input_path);
3504        let _ = fs::remove_file(&output_path);
3505
3506        assert_eq!(exit_code, 0);
3507        assert!(stdout.is_empty());
3508        assert!(stderr.is_empty());
3509        assert_eq!(artifact.media_type, MediaType::Jpeg);
3510    }
3511
3512    #[test]
3513    fn run_with_io_inspects_a_file() {
3514        let path = temp_file_path("inspect");
3515        fs::write(&path, png_bytes()).expect("write temp file");
3516
3517        let mut stdin = Cursor::new(Vec::<u8>::new());
3518        let mut stdout = Vec::new();
3519        let mut stderr = Vec::new();
3520
3521        let exit_code = run_with_io(
3522            vec![
3523                "truss".to_string(),
3524                "inspect".to_string(),
3525                path.display().to_string(),
3526            ],
3527            &mut stdin,
3528            &mut stdout,
3529            &mut stderr,
3530        );
3531
3532        let _ = fs::remove_file(&path);
3533
3534        assert_eq!(exit_code, 0);
3535        assert!(stderr.is_empty());
3536
3537        let output = String::from_utf8(stdout).expect("utf8 stdout");
3538        assert!(output.contains("\"format\": \"png\""));
3539        assert!(output.contains("\"mime\": \"image/png\""));
3540        assert!(output.contains("\"width\": 4"));
3541        assert!(output.contains("\"height\": 3"));
3542        assert!(output.contains("\"hasAlpha\": true"));
3543        assert!(output.contains("\"isAnimated\": false"));
3544    }
3545
3546    #[test]
3547    fn run_with_io_inspects_a_url() {
3548        let (url, handle) = spawn_http_server(png_bytes(), "image/png");
3549        let mut stdin = Cursor::new(Vec::<u8>::new());
3550        let mut stdout = Vec::new();
3551        let mut stderr = Vec::new();
3552
3553        let exit_code = run_with_io(
3554            vec![
3555                "truss".to_string(),
3556                "inspect".to_string(),
3557                "--url".to_string(),
3558                url,
3559            ],
3560            &mut stdin,
3561            &mut stdout,
3562            &mut stderr,
3563        );
3564
3565        handle.join().expect("join server thread");
3566
3567        assert_eq!(exit_code, 0);
3568        assert!(stderr.is_empty());
3569        assert!(
3570            String::from_utf8(stdout)
3571                .expect("utf8 stdout")
3572                .contains("\"format\": \"png\"")
3573        );
3574    }
3575
3576    #[test]
3577    fn run_with_io_converts_a_file_and_infers_output_format_from_extension() {
3578        let input_path = temp_file_path("convert-input");
3579        let output_path = temp_file_path("convert-output").with_extension("jpg");
3580        fs::write(&input_path, png_bytes()).expect("write input file");
3581
3582        let mut stdin = Cursor::new(Vec::<u8>::new());
3583        let mut stdout = Vec::new();
3584        let mut stderr = Vec::new();
3585
3586        let exit_code = run_with_io(
3587            vec![
3588                "truss".to_string(),
3589                "convert".to_string(),
3590                input_path.display().to_string(),
3591                "-o".to_string(),
3592                output_path.display().to_string(),
3593            ],
3594            &mut stdin,
3595            &mut stdout,
3596            &mut stderr,
3597        );
3598
3599        let output_bytes = fs::read(&output_path).expect("read output file");
3600        let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3601
3602        let _ = fs::remove_file(&input_path);
3603        let _ = fs::remove_file(&output_path);
3604
3605        assert_eq!(exit_code, 0);
3606        assert!(stdout.is_empty());
3607        assert!(stderr.is_empty());
3608        assert_eq!(artifact.media_type, MediaType::Jpeg);
3609    }
3610
3611    #[test]
3612    fn run_with_io_optimizes_a_png_file() {
3613        let input_path = temp_file_path("optimize-input");
3614        let output_path = temp_file_path("optimize-output").with_extension("png");
3615        fs::write(&input_path, png_bytes()).expect("write input file");
3616
3617        let mut stdin = Cursor::new(Vec::<u8>::new());
3618        let mut stdout = Vec::new();
3619        let mut stderr = Vec::new();
3620
3621        let exit_code = run_with_io(
3622            vec![
3623                "truss".to_string(),
3624                "optimize".to_string(),
3625                input_path.display().to_string(),
3626                "-o".to_string(),
3627                output_path.display().to_string(),
3628                "--mode".to_string(),
3629                "lossless".to_string(),
3630            ],
3631            &mut stdin,
3632            &mut stdout,
3633            &mut stderr,
3634        );
3635
3636        let output_bytes = fs::read(&output_path).expect("read output file");
3637        let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3638
3639        let _ = fs::remove_file(&input_path);
3640        let _ = fs::remove_file(&output_path);
3641
3642        assert_eq!(exit_code, 0);
3643        assert!(stdout.is_empty());
3644        assert!(stderr.is_empty());
3645        assert_eq!(artifact.media_type, MediaType::Png);
3646    }
3647
3648    #[test]
3649    fn run_with_io_converts_stdin_to_stdout() {
3650        let mut stdin = Cursor::new(png_bytes());
3651        let mut stdout = Vec::new();
3652        let mut stderr = Vec::new();
3653
3654        let exit_code = run_with_io(
3655            vec![
3656                "truss".to_string(),
3657                "convert".to_string(),
3658                "-".to_string(),
3659                "-o".to_string(),
3660                "-".to_string(),
3661                "--format".to_string(),
3662                "png".to_string(),
3663                "--width".to_string(),
3664                "8".to_string(),
3665            ],
3666            &mut stdin,
3667            &mut stdout,
3668            &mut stderr,
3669        );
3670
3671        assert_eq!(
3672            exit_code,
3673            0,
3674            "stderr was: {}",
3675            String::from_utf8_lossy(&stderr)
3676        );
3677        assert!(stderr.is_empty());
3678
3679        let artifact = sniff_artifact(RawArtifact::new(stdout, None)).expect("sniff stdout output");
3680
3681        assert_eq!(artifact.media_type, MediaType::Png);
3682        assert_eq!(artifact.metadata.width, Some(8));
3683    }
3684
3685    #[test]
3686    fn run_with_io_converts_a_url_to_a_file() {
3687        let (url, handle) = spawn_http_server(png_bytes(), "image/png");
3688        let output_path = temp_file_path("convert-url-output").with_extension("png");
3689        let mut stdin = Cursor::new(Vec::<u8>::new());
3690        let mut stdout = Vec::new();
3691        let mut stderr = Vec::new();
3692
3693        let exit_code = run_with_io(
3694            vec![
3695                "truss".to_string(),
3696                "convert".to_string(),
3697                "--url".to_string(),
3698                url,
3699                "-o".to_string(),
3700                output_path.display().to_string(),
3701                "--width".to_string(),
3702                "8".to_string(),
3703            ],
3704            &mut stdin,
3705            &mut stdout,
3706            &mut stderr,
3707        );
3708
3709        handle.join().expect("join server thread");
3710
3711        let output_bytes = fs::read(&output_path).expect("read output file");
3712        let artifact = sniff_artifact(RawArtifact::new(output_bytes, None)).expect("sniff output");
3713        let _ = fs::remove_file(&output_path);
3714
3715        assert_eq!(exit_code, 0);
3716        assert!(stdout.is_empty());
3717        assert!(stderr.is_empty());
3718        assert_eq!(artifact.media_type, MediaType::Png);
3719        assert_eq!(artifact.metadata.width, Some(8));
3720    }
3721
3722    /// A response is the resource only when its status says so, which is what the HTTP
3723    /// server's own fetch has always required and what this adapter did not.
3724    ///
3725    /// Reading a body out of a 3xx that truss does not follow reports the origin's refusal
3726    /// as a problem with the caller's file, and reports it as a success whenever the body
3727    /// happens to sniff as an image. Both are answered here by the class the server gives
3728    /// the same response.
3729    #[rstest]
3730    #[case("300 Multiple Choices")]
3731    #[case("305 Use Proxy")]
3732    #[case("306 Switch Proxy")]
3733    #[case("309 Unassigned")]
3734    #[case("400 Bad Request")]
3735    #[case("500 Internal Server Error")]
3736    fn a_status_that_is_not_success_is_the_origin_failing(#[case] status: &'static str) {
3737        let (url, handle) = spawn_http_server_with_status(status);
3738        let output_path = temp_file_path("convert-url-status").with_extension("png");
3739        let mut stdin = Cursor::new(Vec::<u8>::new());
3740        let mut stdout = Vec::new();
3741        let mut stderr = Vec::new();
3742
3743        let exit_code = run_with_io(
3744            vec![
3745                "truss".to_string(),
3746                "convert".to_string(),
3747                "--url".to_string(),
3748                url,
3749                "-o".to_string(),
3750                output_path.display().to_string(),
3751            ],
3752            &mut stdin,
3753            &mut stdout,
3754            &mut stderr,
3755        );
3756
3757        handle.join().expect("join server thread");
3758        let _ = fs::remove_file(&output_path);
3759        let message = String::from_utf8(stderr).expect("utf8 stderr");
3760
3761        assert_eq!(exit_code, EXIT_IO, "{status} is the origin's failure");
3762        assert!(
3763            message.contains("bad-gateway"),
3764            "{status} must be classified as the origin's failure, got: {message}"
3765        );
3766        let code = status.split(' ').next().expect("status code");
3767        assert!(
3768            message.contains(code),
3769            "{status} must be named in the message, got: {message}"
3770        );
3771    }
3772
3773    /// The other side of the boundary: a success status is the resource, whatever number
3774    /// inside the range it carries.
3775    #[rstest]
3776    #[case("200 OK")]
3777    #[case("201 Created")]
3778    #[case("299 Also Fine")]
3779    fn a_success_status_is_the_resource(#[case] status: &'static str) {
3780        let (url, handle) = spawn_http_server_with_status(status);
3781        let output_path = temp_file_path("convert-url-success").with_extension("png");
3782        let mut stdin = Cursor::new(Vec::<u8>::new());
3783        let mut stdout = Vec::new();
3784        let mut stderr = Vec::new();
3785
3786        let exit_code = run_with_io(
3787            vec![
3788                "truss".to_string(),
3789                "convert".to_string(),
3790                "--url".to_string(),
3791                url,
3792                "-o".to_string(),
3793                output_path.display().to_string(),
3794            ],
3795            &mut stdin,
3796            &mut stdout,
3797            &mut stderr,
3798        );
3799
3800        handle.join().expect("join server thread");
3801        let _ = fs::remove_file(&output_path);
3802
3803        assert_eq!(
3804            exit_code,
3805            0,
3806            "{status} is a representation of the resource: {}",
3807            String::from_utf8_lossy(&stderr)
3808        );
3809    }
3810
3811    /// A remote source the server would fetch has to be one this adapter can be pointed at,
3812    /// which is the argument the watermark cap was already brought into line under.
3813    #[test]
3814    fn the_remote_caps_are_the_ones_the_server_publishes() {
3815        assert_eq!(
3816            MAX_REMOTE_BYTES,
3817            crate::adapters::server::remote::MAX_SOURCE_BYTES,
3818            "a source the server fetches must be one the command line can be pointed at"
3819        );
3820        assert_eq!(
3821            MAX_REMOTE_WATERMARK_BYTES,
3822            crate::adapters::server::remote::MAX_WATERMARK_BYTES,
3823            "a watermark the server refuses is one there is no point in accepting here"
3824        );
3825    }
3826
3827    #[test]
3828    fn run_with_io_reports_input_errors() {
3829        let mut stdin = Cursor::new(Vec::<u8>::new());
3830        let mut stdout = Vec::new();
3831        let mut stderr = Vec::new();
3832
3833        let exit_code = run_with_io(
3834            vec![
3835                "truss".to_string(),
3836                "inspect".to_string(),
3837                "missing-file.png".to_string(),
3838            ],
3839            &mut stdin,
3840            &mut stdout,
3841            &mut stderr,
3842        );
3843
3844        assert_eq!(exit_code, 2);
3845        assert!(stdout.is_empty());
3846        assert!(
3847            String::from_utf8(stderr)
3848                .expect("utf8 stderr")
3849                .contains("failed to read missing-file.png")
3850        );
3851    }
3852
3853    #[test]
3854    fn run_with_io_reports_decode_errors() {
3855        let mut stdin = Cursor::new(vec![1, 2, 3, 4]);
3856        let mut stdout = Vec::new();
3857        let mut stderr = Vec::new();
3858
3859        let exit_code = run_with_io(
3860            vec!["truss".to_string(), "inspect".to_string(), "-".to_string()],
3861            &mut stdin,
3862            &mut stdout,
3863            &mut stderr,
3864        );
3865
3866        assert_eq!(exit_code, 3);
3867        assert!(stdout.is_empty());
3868        assert!(
3869            String::from_utf8(stderr)
3870                .expect("utf8 stderr")
3871                .contains("unknown file signature")
3872        );
3873    }
3874
3875    // ===== Additional tests for -- with convert =====
3876
3877    #[test]
3878    fn double_dash_input_with_options_before() {
3879        // truss convert -o out.jpg --width 100 -- --leading-dash.png
3880        let result = parse_args(vec![
3881            "truss".to_string(),
3882            "convert".to_string(),
3883            "-o".to_string(),
3884            "out.jpg".to_string(),
3885            "--width".to_string(),
3886            "100".to_string(),
3887            "--".to_string(),
3888            "--leading-dash.png".to_string(),
3889        ]);
3890
3891        assert_eq!(
3892            result.unwrap(),
3893            Command::Convert(ConvertCommand {
3894                input: InputSource::Path(PathBuf::from("--leading-dash.png")),
3895                output: OutputTarget::Path(PathBuf::from("out.jpg")),
3896                options: TransformOptions {
3897                    width: Some(100),
3898                    ..TransformOptions::default()
3899                },
3900                watermark_path: None,
3901                watermark_position: None,
3902                watermark_opacity: None,
3903                watermark_margin: None,
3904            })
3905        );
3906    }
3907
3908    // ===== Additional test: inspect -- allows leading dash path =====
3909
3910    #[test]
3911    fn inspect_double_dash_allows_leading_dash() {
3912        let result = parse_args(vec![
3913            "truss".to_string(),
3914            "inspect".to_string(),
3915            "--".to_string(),
3916            "-weird-name.png".to_string(),
3917        ]);
3918
3919        assert_eq!(
3920            result.unwrap(),
3921            Command::Inspect(super::InspectCommand {
3922                input: InputSource::Path(PathBuf::from("-weird-name.png"))
3923            })
3924        );
3925    }
3926
3927    // ===== Completions subcommand =====
3928
3929    #[test]
3930    fn completions_bash_produces_output() {
3931        let mut stdin = Cursor::new(Vec::<u8>::new());
3932        let mut stdout = Vec::new();
3933        let mut stderr = Vec::new();
3934
3935        let exit_code = run_with_io(
3936            vec![
3937                "truss".to_string(),
3938                "completions".to_string(),
3939                "bash".to_string(),
3940            ],
3941            &mut stdin,
3942            &mut stdout,
3943            &mut stderr,
3944        );
3945
3946        assert_eq!(exit_code, 0);
3947        let output = String::from_utf8(stdout).expect("utf8 stdout");
3948        // Bash completions should contain the program name
3949        assert!(
3950            output.contains("truss"),
3951            "bash completions should mention truss"
3952        );
3953    }
3954
3955    #[test]
3956    fn completions_zsh_produces_output() {
3957        let mut stdin = Cursor::new(Vec::<u8>::new());
3958        let mut stdout = Vec::new();
3959        let mut stderr = Vec::new();
3960
3961        let exit_code = run_with_io(
3962            vec![
3963                "truss".to_string(),
3964                "completions".to_string(),
3965                "zsh".to_string(),
3966            ],
3967            &mut stdin,
3968            &mut stdout,
3969            &mut stderr,
3970        );
3971
3972        assert_eq!(exit_code, 0);
3973        assert!(!stdout.is_empty());
3974    }
3975
3976    #[test]
3977    fn completions_fish_produces_output() {
3978        let mut stdin = Cursor::new(Vec::<u8>::new());
3979        let mut stdout = Vec::new();
3980        let mut stderr = Vec::new();
3981
3982        let exit_code = run_with_io(
3983            vec![
3984                "truss".to_string(),
3985                "completions".to_string(),
3986                "fish".to_string(),
3987            ],
3988            &mut stdin,
3989            &mut stdout,
3990            &mut stderr,
3991        );
3992
3993        assert_eq!(exit_code, 0);
3994        assert!(!stdout.is_empty());
3995    }
3996
3997    // ===== Version subcommand =====
3998
3999    #[test]
4000    fn dash_dash_version_prints_version() {
4001        let mut stdin = Cursor::new(Vec::<u8>::new());
4002        let mut stdout = Vec::new();
4003        let mut stderr = Vec::new();
4004
4005        let exit_code = run_with_io(
4006            vec!["truss".to_string(), "--version".to_string()],
4007            &mut stdin,
4008            &mut stdout,
4009            &mut stderr,
4010        );
4011
4012        assert_eq!(exit_code, 0);
4013        let output = String::from_utf8(stdout).expect("utf8 stdout");
4014        assert!(
4015            output.starts_with("truss "),
4016            "version output should start with 'truss ': {output}"
4017        );
4018        assert!(
4019            output.contains(env!("CARGO_PKG_VERSION")),
4020            "should contain package version: {output}"
4021        );
4022    }
4023
4024    #[test]
4025    fn dash_v_prints_version() {
4026        let mut stdin = Cursor::new(Vec::<u8>::new());
4027        let mut stdout = Vec::new();
4028        let mut stderr = Vec::new();
4029
4030        let exit_code = run_with_io(
4031            vec!["truss".to_string(), "-V".to_string()],
4032            &mut stdin,
4033            &mut stdout,
4034            &mut stderr,
4035        );
4036
4037        assert_eq!(exit_code, 0);
4038        let output = String::from_utf8(stdout).expect("utf8 stdout");
4039        assert!(output.contains(env!("CARGO_PKG_VERSION")));
4040    }
4041
4042    #[test]
4043    fn help_includes_version_and_sponsor() {
4044        let mut stdin = Cursor::new(Vec::<u8>::new());
4045        let mut stdout = Vec::new();
4046        let mut stderr = Vec::new();
4047
4048        let exit_code = run_with_io(
4049            vec!["truss".to_string(), "--help".to_string()],
4050            &mut stdin,
4051            &mut stdout,
4052            &mut stderr,
4053        );
4054
4055        assert_eq!(exit_code, 0);
4056        let output = String::from_utf8(stdout).expect("utf8 stdout");
4057        assert!(
4058            output.contains(env!("CARGO_PKG_VERSION")),
4059            "help should include version: {output}"
4060        );
4061        assert!(
4062            output.contains("Sponsor:"),
4063            "help should include sponsor link: {output}"
4064        );
4065        assert!(
4066            output.contains("github.com/sponsors/nao1215"),
4067            "help should include GitHub Sponsors URL: {output}"
4068        );
4069    }
4070
4071    // ===== Fix: extensionless file treated as implicit convert =====
4072
4073    #[test]
4074    fn preprocess_args_extensionless_file_is_implicit_convert() {
4075        // Create a temp file without extension
4076        let dir = temp_dir("extensionless");
4077        let file_path = dir.join("image");
4078        fs::write(&file_path, png_bytes()).expect("write extensionless fixture");
4079
4080        // Use bare filename and set cwd to the temp dir so preprocess_args
4081        // sees a relative name without path separators.
4082        let original_dir = std::env::current_dir().expect("get cwd");
4083        std::env::set_current_dir(&dir).expect("set cwd to temp dir");
4084
4085        let args = vec![
4086            OsString::from("truss"),
4087            OsString::from("image"),
4088            OsString::from("-o"),
4089            OsString::from("out.jpg"),
4090        ];
4091        let result = preprocess_args(args);
4092
4093        std::env::set_current_dir(&original_dir).expect("restore cwd");
4094
4095        assert_eq!(
4096            result[1], "convert",
4097            "extensionless file should trigger implicit convert"
4098        );
4099        assert_eq!(result[2], "image", "bare file name should follow convert");
4100
4101        fs::remove_dir_all(&dir).ok();
4102    }
4103
4104    #[test]
4105    fn preprocess_args_nonexistent_extensionless_is_unknown_subcommand() {
4106        // A name that doesn't exist on disk should pass through (clap handles typo suggestion)
4107        let args = vec![
4108            OsString::from("truss"),
4109            OsString::from("nonexistent_subcommand_xyz"),
4110        ];
4111        let result = preprocess_args(args.clone());
4112        assert_eq!(
4113            result, args,
4114            "non-existent extensionless name should pass through unchanged"
4115        );
4116    }
4117
4118    // ===== Exit code: InvalidOptions maps to EXIT_USAGE (1) =====
4119
4120    #[test]
4121    fn exit_code_invalid_options_is_usage_error() {
4122        // quality=0 triggers InvalidOptions via normalize()
4123        let png_bytes = {
4124            let mut img = image::RgbaImage::new(1, 1);
4125            img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
4126            let mut buf = Vec::new();
4127            let encoder = image::codecs::png::PngEncoder::new(&mut buf);
4128            image::ImageEncoder::write_image(
4129                encoder,
4130                img.as_raw(),
4131                1,
4132                1,
4133                image::ColorType::Rgba8.into(),
4134            )
4135            .unwrap();
4136            buf
4137        };
4138        let mut stdin = Cursor::new(png_bytes);
4139        let mut stdout = Vec::new();
4140        let mut stderr = Vec::new();
4141
4142        let code = run_with_io(
4143            vec![
4144                "truss".to_string(),
4145                "convert".to_string(),
4146                "-".to_string(),
4147                "-o".to_string(),
4148                "-".to_string(),
4149                "--format".to_string(),
4150                "jpeg".to_string(),
4151                "--quality".to_string(),
4152                "0".to_string(),
4153            ],
4154            &mut stdin,
4155            &mut stdout,
4156            &mut stderr,
4157        );
4158        assert_eq!(code, 1, "InvalidOptions should exit with code 1 (usage)");
4159    }
4160
4161    // ===== Help: completions topic =====
4162
4163    #[test]
4164    fn help_completions_shows_completions_help() {
4165        let result = parse_args(vec![
4166            "truss".to_string(),
4167            "help".to_string(),
4168            "completions".to_string(),
4169        ]);
4170        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
4171    }
4172
4173    #[test]
4174    fn help_version_shows_version_help() {
4175        let result = parse_args(vec![
4176            "truss".to_string(),
4177            "help".to_string(),
4178            "version".to_string(),
4179        ]);
4180        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Version));
4181    }
4182
4183    #[test]
4184    fn completions_dash_help_shows_completions_help() {
4185        let result = parse_args(vec![
4186            "truss".to_string(),
4187            "completions".to_string(),
4188            "--help".to_string(),
4189        ]);
4190        assert_eq!(result.unwrap(), Command::Help(HelpTopic::Completions));
4191    }
4192
4193    #[test]
4194    fn completions_without_shell_exits_with_usage_error() {
4195        let mut stdin = Cursor::new(Vec::<u8>::new());
4196        let mut stdout = Vec::new();
4197        let mut stderr = Vec::new();
4198
4199        let code = run_with_io(
4200            vec!["truss".to_string(), "completions".to_string()],
4201            &mut stdin,
4202            &mut stdout,
4203            &mut stderr,
4204        );
4205        assert_eq!(code, 1, "completions without shell arg should exit 1");
4206    }
4207
4208    // ===== Completions: implicit args are present =====
4209
4210    #[test]
4211    fn completions_bash_includes_implicit_args() {
4212        let mut stdin = Cursor::new(Vec::<u8>::new());
4213        let mut stdout = Vec::new();
4214        let mut stderr = Vec::new();
4215
4216        let code = run_with_io(
4217            vec![
4218                "truss".to_string(),
4219                "completions".to_string(),
4220                "bash".to_string(),
4221            ],
4222            &mut stdin,
4223            &mut stdout,
4224            &mut stderr,
4225        );
4226        assert_eq!(code, 0);
4227        let output = String::from_utf8(stdout).expect("utf8 stdout");
4228        assert!(
4229            output.contains("--output"),
4230            "bash completions should include --output for implicit convert"
4231        );
4232        assert!(
4233            output.contains("--bind"),
4234            "bash completions should include --bind for implicit serve"
4235        );
4236    }
4237
4238    // ===== Help text: exit code 5 is documented =====
4239
4240    #[test]
4241    fn help_exit_codes_includes_runtime() {
4242        let text = super::help_top_level();
4243        assert!(
4244            text.contains("5  Runtime error"),
4245            "help text should document exit code 5"
4246        );
4247    }
4248
4249    // ===== Unknown help topic hint lists all topics =====
4250
4251    #[test]
4252    fn unknown_help_topic_hint_lists_all_topics() {
4253        let result = parse_args(vec![
4254            "truss".to_string(),
4255            "help".to_string(),
4256            "nonexistent".to_string(),
4257        ]);
4258        let err = result.unwrap_err();
4259        let hint = err.hint.unwrap();
4260        assert!(hint.contains("completions"), "hint should list completions");
4261        assert!(hint.contains("version"), "hint should list version");
4262    }
4263    // ===== Standard output is flushed before the process exits =====
4264
4265    /// A writer that accepts every write and fails only when it is flushed, which is how
4266    /// a full disk or a closed pipe behaves against a buffered `StdoutLock`.
4267    struct FlushFails;
4268
4269    impl Write for FlushFails {
4270        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
4271            Ok(buf.len())
4272        }
4273
4274        fn flush(&mut self) -> io::Result<()> {
4275            Err(io::Error::new(io::ErrorKind::StorageFull, "no space left"))
4276        }
4277    }
4278
4279    #[test]
4280    fn flush_stdout_turns_a_failed_flush_into_a_runtime_error() {
4281        let mut stderr = Vec::new();
4282        let code = flush_stdout(0, &mut FlushFails, &mut stderr);
4283
4284        assert_eq!(code, 5, "a lost payload must not exit 0");
4285        let text = String::from_utf8(stderr).expect("utf8 stderr");
4286        assert!(
4287            text.contains("error: failed to write stdout"),
4288            "stderr should name the failure, got: {text}"
4289        );
4290    }
4291
4292    #[test]
4293    fn flush_stdout_keeps_the_original_exit_code_when_the_command_already_failed() {
4294        let mut stderr = Vec::new();
4295        let code = flush_stdout(2, &mut FlushFails, &mut stderr);
4296
4297        assert_eq!(code, 2, "the first failure is the one worth reporting");
4298        assert!(stderr.is_empty(), "the flush error should not add noise");
4299    }
4300
4301    #[test]
4302    fn flush_stdout_passes_the_exit_code_through_when_the_flush_succeeds() {
4303        let mut stdout = Vec::new();
4304        let mut stderr = Vec::new();
4305
4306        assert_eq!(flush_stdout(0, &mut stdout, &mut stderr), 0);
4307        assert_eq!(flush_stdout(3, &mut stdout, &mut stderr), 3);
4308        assert!(stderr.is_empty());
4309    }
4310
4311    // ===== help and version explain a failed write like every other command =====
4312
4313    /// A writer that fails on the first write, standing in for a redirect into a full disk.
4314    struct WriteFails;
4315
4316    impl Write for WriteFails {
4317        fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
4318            Err(io::Error::new(io::ErrorKind::StorageFull, "no space left"))
4319        }
4320
4321        fn flush(&mut self) -> io::Result<()> {
4322            Ok(())
4323        }
4324    }
4325
4326    #[test]
4327    fn help_and_version_report_a_failed_write_on_stderr() {
4328        for args in [vec!["truss", "--version"], vec!["truss", "help", "convert"]] {
4329            let mut stdin = Cursor::new(Vec::new());
4330            let mut stderr = Vec::new();
4331            let code = run_with_io(
4332                args.iter().map(|value| (*value).to_string()),
4333                &mut stdin,
4334                &mut WriteFails,
4335                &mut stderr,
4336            );
4337
4338            assert_eq!(
4339                code, 5,
4340                "{args:?} should exit 5 when stdout cannot be written"
4341            );
4342            let text = String::from_utf8(stderr).expect("utf8 stderr");
4343            assert!(
4344                text.contains("error: failed to write stdout"),
4345                "{args:?} should explain itself, got: {text}"
4346            );
4347        }
4348    }
4349
4350    // ===== An output format truss never encodes is a usage error either way =====
4351
4352    #[test]
4353    fn a_gif_output_extension_is_rejected_like_the_gif_flag() {
4354        for args in [
4355            vec!["truss", "convert", "in.png", "-o", "out.gif"],
4356            vec!["truss", "convert", "in.png", "-o", "out.GIF"],
4357            vec!["truss", "optimize", "in.png", "-o", "out.gif"],
4358            vec![
4359                "truss", "convert", "in.png", "-o", "out.png", "--format", "gif",
4360            ],
4361        ] {
4362            let error = parse_args(args.iter().map(|value| (*value).to_string()))
4363                .expect_err("gif output should not parse");
4364
4365            assert_eq!(error.exit_code, 1, "{args:?} should be a usage error");
4366            assert!(
4367                error.message.contains("input-only format")
4368                    && error.message.contains("png, jpeg, webp, or avif"),
4369                "{args:?} should name the alternatives, got: {}",
4370                error.message
4371            );
4372        }
4373    }
4374
4375    #[test]
4376    fn an_explicit_format_overrides_an_unencodable_output_extension() {
4377        let command = parse_args(
4378            [
4379                "truss", "convert", "in.png", "-o", "out.gif", "--format", "png",
4380            ]
4381            .iter()
4382            .map(|value| (*value).to_string()),
4383        )
4384        .expect("an explicit format decides the encoder, whatever the extension says");
4385
4386        match command {
4387            Command::Convert(convert) => assert_eq!(convert.options.format, Some(MediaType::Png)),
4388            other => panic!("expected a convert command, got {other:?}"),
4389        }
4390    }
4391
4392    // ===== A path argument that is not valid UTF-8 =====
4393
4394    /// A file name on Linux is a byte string, so `truss convert` has to take one whatever
4395    /// bytes it holds. Reading the arguments as `String` panicked before the command line
4396    /// was parsed, which no adapter could report and no exit code covered.
4397    ///
4398    /// Only Linux runs this: APFS refuses to create a name that is not valid UTF-8, with
4399    /// `EILSEQ`, so the fixture cannot exist on macOS, and a Windows name is UTF-16. The
4400    /// panic itself was not filesystem-specific, and the flag-value case below covers the
4401    /// platforms this one skips.
4402    #[cfg(target_os = "linux")]
4403    #[test]
4404    fn a_non_utf8_input_path_is_converted_rather_than_refused() {
4405        use std::os::unix::ffi::OsStringExt;
4406
4407        let dir = temp_dir("non-utf8-input");
4408        let mut name = OsString::from_vec(b"caf\xe9".to_vec());
4409        name.push(".png");
4410        let input_path = dir.join(name);
4411        fs::write(&input_path, png_bytes()).expect("write input file");
4412        let output_path = dir.join("out.png");
4413
4414        let mut stdin = Cursor::new(Vec::<u8>::new());
4415        let mut stdout = Vec::new();
4416        let mut stderr = Vec::new();
4417
4418        let exit_code = run_with_io(
4419            vec![
4420                OsString::from("truss"),
4421                OsString::from("convert"),
4422                input_path.clone().into_os_string(),
4423                OsString::from("-o"),
4424                output_path.clone().into_os_string(),
4425                OsString::from("--width"),
4426                OsString::from("2"),
4427            ],
4428            &mut stdin,
4429            &mut stdout,
4430            &mut stderr,
4431        );
4432
4433        let output_exists = output_path.is_file();
4434        let _ = fs::remove_dir_all(&dir);
4435
4436        assert_eq!(
4437            exit_code,
4438            0,
4439            "stderr was: {}",
4440            String::from_utf8_lossy(&stderr)
4441        );
4442        assert!(
4443            output_exists,
4444            "the conversion should have written an output"
4445        );
4446    }
4447
4448    /// The bytes have to survive on the way out as well: a lossy conversion of the path
4449    /// would write to a name nobody asked for and report success. Linux only, for the
4450    /// reason the test above gives.
4451    #[cfg(target_os = "linux")]
4452    #[test]
4453    fn a_non_utf8_output_path_is_written_under_exactly_those_bytes() {
4454        use std::os::unix::ffi::OsStringExt;
4455
4456        let dir = temp_dir("non-utf8-output");
4457        let input_path = dir.join("in.png");
4458        fs::write(&input_path, png_bytes()).expect("write input file");
4459        let mut name = OsString::from_vec(b"sortie-\xe9".to_vec());
4460        name.push(".png");
4461        let output_path = dir.join(name);
4462
4463        let mut stdin = Cursor::new(Vec::<u8>::new());
4464        let mut stdout = Vec::new();
4465        let mut stderr = Vec::new();
4466
4467        let exit_code = run_with_io(
4468            vec![
4469                OsString::from("truss"),
4470                OsString::from("convert"),
4471                input_path.into_os_string(),
4472                OsString::from("-o"),
4473                output_path.clone().into_os_string(),
4474            ],
4475            &mut stdin,
4476            &mut stdout,
4477            &mut stderr,
4478        );
4479
4480        let output_exists = output_path.is_file();
4481        let _ = fs::remove_dir_all(&dir);
4482
4483        assert_eq!(
4484            exit_code,
4485            0,
4486            "stderr was: {}",
4487            String::from_utf8_lossy(&stderr)
4488        );
4489        assert!(
4490            output_exists,
4491            "the output should exist under the bytes that were asked for"
4492        );
4493    }
4494
4495    /// A flag whose value is genuinely text keeps refusing bytes that are not text, with
4496    /// the usage error every other bad flag value gets rather than a panic.
4497    #[cfg(unix)]
4498    #[test]
4499    fn a_non_utf8_value_for_a_text_flag_is_a_usage_error() {
4500        use std::os::unix::ffi::OsStringExt;
4501
4502        let mut stdin = Cursor::new(png_bytes());
4503        let mut stdout = Vec::new();
4504        let mut stderr = Vec::new();
4505
4506        let exit_code = run_with_io(
4507            vec![
4508                OsString::from("truss"),
4509                OsString::from("convert"),
4510                OsString::from("-"),
4511                OsString::from("-o"),
4512                OsString::from("-"),
4513                OsString::from("--format"),
4514                OsString::from_vec(vec![0xff]),
4515            ],
4516            &mut stdin,
4517            &mut stdout,
4518            &mut stderr,
4519        );
4520
4521        assert_eq!(exit_code, EXIT_USAGE, "a value that is not text is usage");
4522        assert!(stdout.is_empty(), "nothing should be written on a refusal");
4523    }
4524
4525    // ===== The class of a file system fault =====
4526
4527    /// The watermark is a source the command line named, so a missing one is the same
4528    /// class as a missing input. `internal-error` says the fault is truss's own.
4529    #[test]
4530    fn a_missing_watermark_is_not_found_like_a_missing_input() {
4531        let dir = temp_dir("watermark-class");
4532        let input_path = dir.join("in.png");
4533        fs::write(&input_path, png_bytes()).expect("write input file");
4534        let output_path = dir.join("out.png");
4535
4536        let mut stdin = Cursor::new(Vec::<u8>::new());
4537        let mut stdout = Vec::new();
4538        let mut stderr = Vec::new();
4539
4540        let exit_code = run_with_io(
4541            vec![
4542                "truss".to_string(),
4543                "convert".to_string(),
4544                input_path.display().to_string(),
4545                "-o".to_string(),
4546                output_path.display().to_string(),
4547                "--watermark".to_string(),
4548                dir.join("absent.png").display().to_string(),
4549            ],
4550            &mut stdin,
4551            &mut stdout,
4552            &mut stderr,
4553        );
4554
4555        let message = String::from_utf8(stderr).expect("utf8 stderr");
4556        let _ = fs::remove_dir_all(&dir);
4557
4558        assert_eq!(exit_code, 2, "a file system fault is exit 2");
4559        assert!(
4560            message.contains("(not-found)"),
4561            "a missing watermark should be not-found, got: {message}"
4562        );
4563    }
4564
4565    /// A watermark that is there and cannot be read is the other class, which is what
4566    /// keeps the shared rule honest rather than replacing one blanket answer with another.
4567    #[cfg(unix)]
4568    #[test]
4569    fn an_unreadable_watermark_is_an_internal_error() {
4570        use std::os::unix::fs::PermissionsExt;
4571
4572        let dir = temp_dir("watermark-unreadable");
4573        let input_path = dir.join("in.png");
4574        fs::write(&input_path, png_bytes()).expect("write input file");
4575        let watermark_path = dir.join("wm.png");
4576        fs::write(&watermark_path, png_bytes()).expect("write watermark file");
4577        fs::set_permissions(&watermark_path, fs::Permissions::from_mode(0o000))
4578            .expect("make the watermark unreadable");
4579        let output_path = dir.join("out.png");
4580
4581        let mut stdin = Cursor::new(Vec::<u8>::new());
4582        let mut stdout = Vec::new();
4583        let mut stderr = Vec::new();
4584
4585        let exit_code = run_with_io(
4586            vec![
4587                "truss".to_string(),
4588                "convert".to_string(),
4589                input_path.display().to_string(),
4590                "-o".to_string(),
4591                output_path.display().to_string(),
4592                "--watermark".to_string(),
4593                watermark_path.display().to_string(),
4594            ],
4595            &mut stdin,
4596            &mut stdout,
4597            &mut stderr,
4598        );
4599
4600        let message = String::from_utf8(stderr).expect("utf8 stderr");
4601        let _ = fs::set_permissions(&watermark_path, fs::Permissions::from_mode(0o644));
4602        let _ = fs::remove_dir_all(&dir);
4603
4604        if message.contains("(not-found)") {
4605            // Running as root reads it anyway, and the case has nothing to assert.
4606            return;
4607        }
4608        assert_eq!(exit_code, 2, "a file system fault is exit 2");
4609        assert!(
4610            message.contains("(internal-error)"),
4611            "a watermark that cannot be read is not a missing one, got: {message}"
4612        );
4613    }
4614
4615    // ===== stderr is one line per failure =====
4616
4617    /// The failure a real decoder raises, end to end.
4618    ///
4619    /// `truncated.jpg` is a JPEG whose bytes stop early, which is what an upload cut off
4620    /// by a dropped connection looks like, and the decoder's own wording for it ends with
4621    /// a newline.
4622    #[test]
4623    fn a_truncated_jpeg_is_reported_on_one_line() {
4624        const TRUNCATED_JPEG: &[u8] = include_bytes!("../../../integration/fixtures/truncated.jpg");
4625
4626        let mut stdin = Cursor::new(TRUNCATED_JPEG.to_vec());
4627        let mut stdout = Vec::new();
4628        let mut stderr = Vec::new();
4629
4630        let exit_code = run_with_io(
4631            vec![
4632                "truss".to_string(),
4633                "convert".to_string(),
4634                "-".to_string(),
4635                "-o".to_string(),
4636                "-".to_string(),
4637                "--format".to_string(),
4638                "png".to_string(),
4639            ],
4640            &mut stdin,
4641            &mut stdout,
4642            &mut stderr,
4643        );
4644
4645        let rendered = String::from_utf8(stderr).expect("utf8 stderr");
4646
4647        assert_eq!(exit_code, EXIT_TRANSFORM);
4648        assert_eq!(
4649            rendered.lines().count(),
4650            1,
4651            "one failure is one line, got: {rendered:?}"
4652        );
4653        assert!(
4654            rendered.trim_end().ends_with("(decode-failed)"),
4655            "the class ends the line, got: {rendered:?}"
4656        );
4657    }
4658
4659    /// The class is the last thing on the line, so a message carrying a newline puts it on
4660    /// a line of its own where a caller reading the last line cannot find the failure.
4661    #[test]
4662    fn write_error_keeps_a_message_with_a_newline_on_one_line() {
4663        let mut stderr = Vec::new();
4664        let code = super::write_error(
4665            &mut stderr,
4666            super::classified_error(
4667                crate::core::error_class::ErrorClass::DecodeFailed,
4668                EXIT_TRANSFORM,
4669                "decoding failed\n",
4670            ),
4671        );
4672
4673        let rendered = String::from_utf8(stderr).expect("utf8 stderr");
4674
4675        assert_eq!(code, EXIT_TRANSFORM);
4676        assert_eq!(
4677            rendered.lines().count(),
4678            1,
4679            "one failure is one line, got: {rendered:?}"
4680        );
4681        assert!(
4682            rendered.ends_with("(decode-failed)\n"),
4683            "the class ends the line, got: {rendered:?}"
4684        );
4685    }
4686
4687    #[test]
4688    fn svg_output_from_a_raster_input_names_the_rule_it_broke() {
4689        let error = crate::TransformError::UnsupportedOutputMediaType(MediaType::Svg).to_string();
4690
4691        assert!(
4692            error.contains("requires an svg input"),
4693            "the message should say why svg was refused, got: {error}"
4694        );
4695    }
4696}