Skip to main content

agx_cli/
lib.rs

1//! AgX command-line interface.
2//!
3//! See the [project site](https://zhjngli.github.io/AgX/reference/cli.html)
4//! for the full CLI reference.
5
6#![deny(missing_docs)]
7#![deny(rustdoc::broken_intra_doc_links)]
8
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use clap::{Args, CommandFactory, Parser, Subcommand};
13
14pub mod output;
15pub mod validate;
16
17/// Create an engine with the appropriate pipeline based on the `--gpu` flag.
18pub fn create_engine(image: image::Rgb32FImage, use_gpu: bool) -> agx::Engine {
19    if use_gpu {
20        #[cfg(feature = "gpu")]
21        return agx::Engine::new_gpu_auto(image);
22        #[cfg(not(feature = "gpu"))]
23        eprintln!("Warning: --gpu requires the 'gpu' feature; using CPU");
24    }
25    agx::Engine::new(image)
26}
27
28/// Top-level CLI arguments.
29#[derive(Parser)]
30#[command(name = "agx", about = "Photo editing CLI with portable TOML presets")]
31pub struct Cli {
32    /// Use GPU acceleration (opt-in). Falls back to CPU if no GPU is available.
33    #[arg(long, global = true)]
34    pub gpu: bool,
35    /// Selected subcommand and its arguments.
36    #[command(subcommand)]
37    pub command: Commands,
38}
39
40/// Output encoding options shared by all commands.
41#[derive(Args)]
42pub struct OutputOpts {
43    /// JPEG output quality (1-100, default 92)
44    #[arg(long, default_value_t = 92)]
45    pub quality: u8,
46    /// Output format (jpeg, png, tiff). Inferred from extension if not specified.
47    #[arg(long)]
48    format: Option<String>,
49    /// Output color space: srgb (default), p3 (Display P3), or adobe-rgb.
50    /// Converts the image into the chosen gamut and embeds the matching ICC.
51    #[arg(long, default_value_t = agx::encode::OutputGamut::Srgb)]
52    pub output_gamut: agx::encode::OutputGamut,
53    /// Write profiling timing data to this JSON file (requires --features profiling)
54    #[cfg(feature = "profiling")]
55    #[arg(long)]
56    pub profile_output: Option<PathBuf>,
57}
58
59impl OutputOpts {
60    /// Parse the explicit output format, if provided.
61    pub fn parse_format(&self) -> agx::Result<Option<agx::encode::OutputFormat>> {
62        self.format.as_deref().map(parse_output_format).transpose()
63    }
64
65    /// Build encoder options from the CLI flags.
66    pub fn encode_options(&self) -> agx::Result<agx::encode::EncodeOptions> {
67        Ok(agx::encode::EncodeOptions {
68            jpeg_quality: self.quality,
69            format: self.parse_format()?,
70            output_gamut: self.output_gamut,
71        })
72    }
73}
74
75/// Per-channel HSL adjustment arguments.
76#[derive(Args)]
77pub struct HslArgs {
78    /// Red hue shift (-180 to +180 degrees)
79    #[arg(
80        long = "hsl-red-hue",
81        visible_alias = "hsl-red-h",
82        default_value_t = 0.0,
83        allow_hyphen_values = true
84    )]
85    hsl_red_hue: f32,
86    /// Red saturation (-100 to +100)
87    #[arg(
88        long = "hsl-red-saturation",
89        visible_alias = "hsl-red-s",
90        default_value_t = 0.0,
91        allow_hyphen_values = true
92    )]
93    hsl_red_saturation: f32,
94    /// Red luminance (-100 to +100)
95    #[arg(
96        long = "hsl-red-luminance",
97        visible_alias = "hsl-red-l",
98        default_value_t = 0.0,
99        allow_hyphen_values = true
100    )]
101    hsl_red_luminance: f32,
102
103    /// Orange hue shift (-180 to +180 degrees)
104    #[arg(
105        long = "hsl-orange-hue",
106        visible_alias = "hsl-orange-h",
107        default_value_t = 0.0,
108        allow_hyphen_values = true
109    )]
110    hsl_orange_hue: f32,
111    /// Orange saturation (-100 to +100)
112    #[arg(
113        long = "hsl-orange-saturation",
114        visible_alias = "hsl-orange-s",
115        default_value_t = 0.0,
116        allow_hyphen_values = true
117    )]
118    hsl_orange_saturation: f32,
119    /// Orange luminance (-100 to +100)
120    #[arg(
121        long = "hsl-orange-luminance",
122        visible_alias = "hsl-orange-l",
123        default_value_t = 0.0,
124        allow_hyphen_values = true
125    )]
126    hsl_orange_luminance: f32,
127
128    /// Yellow hue shift (-180 to +180 degrees)
129    #[arg(
130        long = "hsl-yellow-hue",
131        visible_alias = "hsl-yellow-h",
132        default_value_t = 0.0,
133        allow_hyphen_values = true
134    )]
135    hsl_yellow_hue: f32,
136    /// Yellow saturation (-100 to +100)
137    #[arg(
138        long = "hsl-yellow-saturation",
139        visible_alias = "hsl-yellow-s",
140        default_value_t = 0.0,
141        allow_hyphen_values = true
142    )]
143    hsl_yellow_saturation: f32,
144    /// Yellow luminance (-100 to +100)
145    #[arg(
146        long = "hsl-yellow-luminance",
147        visible_alias = "hsl-yellow-l",
148        default_value_t = 0.0,
149        allow_hyphen_values = true
150    )]
151    hsl_yellow_luminance: f32,
152
153    /// Green hue shift (-180 to +180 degrees)
154    #[arg(
155        long = "hsl-green-hue",
156        visible_alias = "hsl-green-h",
157        default_value_t = 0.0,
158        allow_hyphen_values = true
159    )]
160    hsl_green_hue: f32,
161    /// Green saturation (-100 to +100)
162    #[arg(
163        long = "hsl-green-saturation",
164        visible_alias = "hsl-green-s",
165        default_value_t = 0.0,
166        allow_hyphen_values = true
167    )]
168    hsl_green_saturation: f32,
169    /// Green luminance (-100 to +100)
170    #[arg(
171        long = "hsl-green-luminance",
172        visible_alias = "hsl-green-l",
173        default_value_t = 0.0,
174        allow_hyphen_values = true
175    )]
176    hsl_green_luminance: f32,
177
178    /// Aqua hue shift (-180 to +180 degrees)
179    #[arg(
180        long = "hsl-aqua-hue",
181        visible_alias = "hsl-aqua-h",
182        default_value_t = 0.0,
183        allow_hyphen_values = true
184    )]
185    hsl_aqua_hue: f32,
186    /// Aqua saturation (-100 to +100)
187    #[arg(
188        long = "hsl-aqua-saturation",
189        visible_alias = "hsl-aqua-s",
190        default_value_t = 0.0,
191        allow_hyphen_values = true
192    )]
193    hsl_aqua_saturation: f32,
194    /// Aqua luminance (-100 to +100)
195    #[arg(
196        long = "hsl-aqua-luminance",
197        visible_alias = "hsl-aqua-l",
198        default_value_t = 0.0,
199        allow_hyphen_values = true
200    )]
201    hsl_aqua_luminance: f32,
202
203    /// Blue hue shift (-180 to +180 degrees)
204    #[arg(
205        long = "hsl-blue-hue",
206        visible_alias = "hsl-blue-h",
207        default_value_t = 0.0,
208        allow_hyphen_values = true
209    )]
210    hsl_blue_hue: f32,
211    /// Blue saturation (-100 to +100)
212    #[arg(
213        long = "hsl-blue-saturation",
214        visible_alias = "hsl-blue-s",
215        default_value_t = 0.0,
216        allow_hyphen_values = true
217    )]
218    hsl_blue_saturation: f32,
219    /// Blue luminance (-100 to +100)
220    #[arg(
221        long = "hsl-blue-luminance",
222        visible_alias = "hsl-blue-l",
223        default_value_t = 0.0,
224        allow_hyphen_values = true
225    )]
226    hsl_blue_luminance: f32,
227
228    /// Purple hue shift (-180 to +180 degrees)
229    #[arg(
230        long = "hsl-purple-hue",
231        visible_alias = "hsl-purple-h",
232        default_value_t = 0.0,
233        allow_hyphen_values = true
234    )]
235    hsl_purple_hue: f32,
236    /// Purple saturation (-100 to +100)
237    #[arg(
238        long = "hsl-purple-saturation",
239        visible_alias = "hsl-purple-s",
240        default_value_t = 0.0,
241        allow_hyphen_values = true
242    )]
243    hsl_purple_saturation: f32,
244    /// Purple luminance (-100 to +100)
245    #[arg(
246        long = "hsl-purple-luminance",
247        visible_alias = "hsl-purple-l",
248        default_value_t = 0.0,
249        allow_hyphen_values = true
250    )]
251    hsl_purple_luminance: f32,
252
253    /// Magenta hue shift (-180 to +180 degrees)
254    #[arg(
255        long = "hsl-magenta-hue",
256        visible_alias = "hsl-magenta-h",
257        default_value_t = 0.0,
258        allow_hyphen_values = true
259    )]
260    hsl_magenta_hue: f32,
261    /// Magenta saturation (-100 to +100)
262    #[arg(
263        long = "hsl-magenta-saturation",
264        visible_alias = "hsl-magenta-s",
265        default_value_t = 0.0,
266        allow_hyphen_values = true
267    )]
268    hsl_magenta_saturation: f32,
269    /// Magenta luminance (-100 to +100)
270    #[arg(
271        long = "hsl-magenta-luminance",
272        visible_alias = "hsl-magenta-l",
273        default_value_t = 0.0,
274        allow_hyphen_values = true
275    )]
276    hsl_magenta_luminance: f32,
277}
278
279impl HslArgs {
280    fn to_hsl_channels(&self) -> agx::HslChannels {
281        agx::HslChannels {
282            red: agx::HslChannel {
283                hue: self.hsl_red_hue,
284                saturation: self.hsl_red_saturation,
285                luminance: self.hsl_red_luminance,
286            },
287            orange: agx::HslChannel {
288                hue: self.hsl_orange_hue,
289                saturation: self.hsl_orange_saturation,
290                luminance: self.hsl_orange_luminance,
291            },
292            yellow: agx::HslChannel {
293                hue: self.hsl_yellow_hue,
294                saturation: self.hsl_yellow_saturation,
295                luminance: self.hsl_yellow_luminance,
296            },
297            green: agx::HslChannel {
298                hue: self.hsl_green_hue,
299                saturation: self.hsl_green_saturation,
300                luminance: self.hsl_green_luminance,
301            },
302            aqua: agx::HslChannel {
303                hue: self.hsl_aqua_hue,
304                saturation: self.hsl_aqua_saturation,
305                luminance: self.hsl_aqua_luminance,
306            },
307            blue: agx::HslChannel {
308                hue: self.hsl_blue_hue,
309                saturation: self.hsl_blue_saturation,
310                luminance: self.hsl_blue_luminance,
311            },
312            purple: agx::HslChannel {
313                hue: self.hsl_purple_hue,
314                saturation: self.hsl_purple_saturation,
315                luminance: self.hsl_purple_luminance,
316            },
317            magenta: agx::HslChannel {
318                hue: self.hsl_magenta_hue,
319                saturation: self.hsl_magenta_saturation,
320                luminance: self.hsl_magenta_luminance,
321            },
322        }
323    }
324}
325
326/// Inline editing parameters (tone, white balance, LUT, HSL).
327#[derive(Args)]
328pub struct EditArgs {
329    /// Exposure in stops (-5.0 to +5.0)
330    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
331    exposure: f32,
332    /// Contrast (-100 to +100)
333    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
334    contrast: f32,
335    /// Highlights (-100 to +100)
336    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
337    highlights: f32,
338    /// Shadows (-100 to +100)
339    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
340    shadows: f32,
341    /// Whites (-100 to +100)
342    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
343    whites: f32,
344    /// Blacks (-100 to +100)
345    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
346    blacks: f32,
347    /// White balance temperature shift
348    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
349    temperature: f32,
350    /// White balance tint shift
351    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
352    tint: f32,
353    /// Path to a .cube LUT file
354    #[arg(long)]
355    lut: Option<PathBuf>,
356
357    /// Vignette amount (-100 to +100). Negative darkens edges, positive brightens.
358    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
359    vignette_amount: f32,
360    /// Vignette shape: elliptical (default) or circular
361    #[arg(long, default_value = "elliptical")]
362    vignette_shape: agx::VignetteShape,
363
364    // --- Color grading ---
365    /// Color grading: shadow wheel hue (0-360 degrees)
366    #[arg(long = "cg-shadows-hue", default_value_t = 0.0)]
367    cg_shadows_hue: f32,
368    /// Color grading: shadow wheel saturation (0-100)
369    #[arg(long = "cg-shadows-sat", default_value_t = 0.0)]
370    cg_shadows_sat: f32,
371    /// Color grading: shadow wheel luminance (-100 to +100)
372    #[arg(
373        long = "cg-shadows-lum",
374        default_value_t = 0.0,
375        allow_hyphen_values = true
376    )]
377    cg_shadows_lum: f32,
378    /// Color grading: midtone wheel hue (0-360 degrees)
379    #[arg(long = "cg-midtones-hue", default_value_t = 0.0)]
380    cg_midtones_hue: f32,
381    /// Color grading: midtone wheel saturation (0-100)
382    #[arg(long = "cg-midtones-sat", default_value_t = 0.0)]
383    cg_midtones_sat: f32,
384    /// Color grading: midtone wheel luminance (-100 to +100)
385    #[arg(
386        long = "cg-midtones-lum",
387        default_value_t = 0.0,
388        allow_hyphen_values = true
389    )]
390    cg_midtones_lum: f32,
391    /// Color grading: highlight wheel hue (0-360 degrees)
392    #[arg(long = "cg-highlights-hue", default_value_t = 0.0)]
393    cg_highlights_hue: f32,
394    /// Color grading: highlight wheel saturation (0-100)
395    #[arg(long = "cg-highlights-sat", default_value_t = 0.0)]
396    cg_highlights_sat: f32,
397    /// Color grading: highlight wheel luminance (-100 to +100)
398    #[arg(
399        long = "cg-highlights-lum",
400        default_value_t = 0.0,
401        allow_hyphen_values = true
402    )]
403    cg_highlights_lum: f32,
404    /// Color grading: global wheel hue (0-360 degrees)
405    #[arg(long = "cg-global-hue", default_value_t = 0.0)]
406    cg_global_hue: f32,
407    /// Color grading: global wheel saturation (0-100)
408    #[arg(long = "cg-global-sat", default_value_t = 0.0)]
409    cg_global_sat: f32,
410    /// Color grading: global wheel luminance (-100 to +100)
411    #[arg(
412        long = "cg-global-lum",
413        default_value_t = 0.0,
414        allow_hyphen_values = true
415    )]
416    cg_global_lum: f32,
417    /// Color grading: shadow/highlight balance (-100 to +100)
418    #[arg(long = "cg-balance", default_value_t = 0.0, allow_hyphen_values = true)]
419    cg_balance: f32,
420
421    /// Tone curve — RGB master channel points (e.g. "0.0:0.0,0.25:0.15,0.75:0.85,1.0:1.0")
422    #[arg(long = "tc-rgb")]
423    tc_rgb: Option<String>,
424    /// Tone curve — Luminance channel points
425    #[arg(long = "tc-luma")]
426    tc_luma: Option<String>,
427    /// Tone curve — Red channel points
428    #[arg(long = "tc-red")]
429    tc_red: Option<String>,
430    /// Tone curve — Green channel points
431    #[arg(long = "tc-green")]
432    tc_green: Option<String>,
433    /// Tone curve — Blue channel points
434    #[arg(long = "tc-blue")]
435    tc_blue: Option<String>,
436
437    /// Sharpening amount (0-100)
438    #[arg(long = "sharpen-amount", default_value_t = 0.0)]
439    sharpen_amount: f32,
440    /// Sharpening radius / sigma (0.5-3.0)
441    #[arg(long = "sharpen-radius", default_value_t = 1.0)]
442    sharpen_radius: f32,
443    /// Sharpening threshold (0-100). Higher = sharpen finer detail.
444    #[arg(long = "sharpen-threshold", default_value_t = 25.0)]
445    sharpen_threshold: f32,
446    /// Sharpening masking (0-100). Limits sharpening to textured areas.
447    #[arg(long = "sharpen-masking", default_value_t = 0.0)]
448    sharpen_masking: f32,
449    /// Clarity: local contrast at medium frequencies (-100 to +100)
450    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
451    clarity: f32,
452    /// Texture: local contrast at high frequencies (-100 to +100)
453    #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
454    texture: f32,
455
456    /// Dehaze amount (-100 to +100). Positive removes haze, negative adds haze.
457    #[arg(
458        long = "dehaze-amount",
459        default_value_t = 0.0,
460        allow_hyphen_values = true
461    )]
462    dehaze_amount: f32,
463
464    /// Noise reduction: luminance strength (0-100)
465    #[arg(long = "nr-luminance", default_value_t = 0.0)]
466    nr_luminance: f32,
467    /// Noise reduction: color strength (0-100)
468    #[arg(long = "nr-color", default_value_t = 0.0)]
469    nr_color: f32,
470    /// Noise reduction: detail preservation (0-100)
471    #[arg(long = "nr-detail", default_value_t = 0.0)]
472    nr_detail: f32,
473
474    /// Grain type (fine, silver, harsh)
475    #[arg(long = "grain-type", default_value_t = agx::GrainType::Silver)]
476    grain_type: agx::GrainType,
477    /// Grain amount (0-100)
478    #[arg(long = "grain-amount", default_value_t = 0.0)]
479    grain_amount: f32,
480    /// Grain size (0-100)
481    #[arg(long = "grain-size", default_value_t = 50.0)]
482    grain_size: f32,
483
484    #[command(flatten)]
485    hsl: HslArgs,
486}
487
488fn parse_curve_points(s: &str) -> Result<agx::ToneCurve, String> {
489    let mut points = Vec::new();
490    for pair in s.split(',') {
491        let pair = pair.trim();
492        let parts: Vec<&str> = pair.split(':').collect();
493        if parts.len() != 2 {
494            return Err(format!("invalid point '{pair}', expected x:y"));
495        }
496        let x: f32 = parts[0]
497            .trim()
498            .parse()
499            .map_err(|_| format!("invalid x value in '{pair}'"))?;
500        let y: f32 = parts[1]
501            .trim()
502            .parse()
503            .map_err(|_| format!("invalid y value in '{pair}'"))?;
504        points.push((x, y));
505    }
506    let curve = agx::ToneCurve { points };
507    curve.validate()?;
508    Ok(curve)
509}
510
511impl EditArgs {
512    /// Convert CLI edit flags into render parameters.
513    pub fn to_params(&self) -> agx::Result<agx::Parameters> {
514        fn parse_tc(flag: &Option<String>) -> agx::Result<agx::ToneCurve> {
515            match flag {
516                Some(s) => parse_curve_points(s)
517                    .map_err(|e| agx::AgxError::Preset(format!("Error parsing tone curve: {e}"))),
518                None => Ok(agx::ToneCurve::default()),
519            }
520        }
521
522        Ok(agx::Parameters {
523            exposure: self.exposure,
524            contrast: self.contrast,
525            highlights: self.highlights,
526            shadows: self.shadows,
527            whites: self.whites,
528            blacks: self.blacks,
529            temperature: self.temperature,
530            tint: self.tint,
531            hsl: self.hsl.to_hsl_channels(),
532            vignette: agx::VignetteParams {
533                amount: self.vignette_amount,
534                shape: self.vignette_shape,
535            },
536            color_grading: agx::ColorGradingParams {
537                shadows: agx::ColorWheel {
538                    hue: self.cg_shadows_hue,
539                    saturation: self.cg_shadows_sat,
540                    luminance: self.cg_shadows_lum,
541                },
542                midtones: agx::ColorWheel {
543                    hue: self.cg_midtones_hue,
544                    saturation: self.cg_midtones_sat,
545                    luminance: self.cg_midtones_lum,
546                },
547                highlights: agx::ColorWheel {
548                    hue: self.cg_highlights_hue,
549                    saturation: self.cg_highlights_sat,
550                    luminance: self.cg_highlights_lum,
551                },
552                global: agx::ColorWheel {
553                    hue: self.cg_global_hue,
554                    saturation: self.cg_global_sat,
555                    luminance: self.cg_global_lum,
556                },
557                balance: self.cg_balance,
558            },
559            tone_curve: agx::ToneCurveParams {
560                rgb: parse_tc(&self.tc_rgb)?,
561                luma: parse_tc(&self.tc_luma)?,
562                red: parse_tc(&self.tc_red)?,
563                green: parse_tc(&self.tc_green)?,
564                blue: parse_tc(&self.tc_blue)?,
565            },
566            detail: agx::DetailParams {
567                sharpening: agx::SharpeningParams {
568                    amount: self.sharpen_amount,
569                    radius: self.sharpen_radius,
570                    threshold: self.sharpen_threshold,
571                    masking: self.sharpen_masking,
572                },
573                clarity: self.clarity,
574                texture: self.texture,
575            },
576            dehaze: agx::DehazeParams {
577                amount: self.dehaze_amount,
578            },
579            noise_reduction: agx::NoiseReductionParams {
580                luminance: self.nr_luminance,
581                color: self.nr_color,
582                detail: self.nr_detail,
583            },
584            grain: agx::GrainParams {
585                grain_type: self.grain_type,
586                amount: self.grain_amount,
587                size: self.grain_size,
588                seed: None,
589            },
590        })
591    }
592
593    /// Load the optional LUT file referenced by the CLI flags.
594    pub fn load_lut(&self) -> agx::Result<Option<Arc<agx::Lut3D>>> {
595        match &self.lut {
596            Some(lut_path) => Ok(Some(Arc::new(agx::Lut3D::from_cube_file(lut_path)?))),
597            None => Ok(None),
598        }
599    }
600}
601
602/// Batch processing options shared by batch-apply and batch-edit.
603#[derive(Args)]
604pub struct BatchOpts {
605    /// Directory containing input images
606    #[arg(long)]
607    pub input_dir: PathBuf,
608    /// Directory for output images (created if missing)
609    #[arg(long)]
610    pub output_dir: PathBuf,
611    /// Recurse into subdirectories
612    #[arg(short, long, default_value_t = false)]
613    pub recursive: bool,
614    /// Number of parallel workers (0 = auto-detect CPU cores)
615    #[arg(short, long, default_value_t = 0)]
616    pub jobs: usize,
617    /// Continue processing when individual files fail
618    #[arg(long, default_value_t = false)]
619    pub skip_errors: bool,
620    /// Append suffix to output filenames (e.g., `_edited`)
621    #[arg(long)]
622    pub suffix: Option<String>,
623
624    /// Shared output encoding options for each batch result.
625    #[command(flatten)]
626    pub output: OutputOpts,
627}
628
629/// Output format for commands that support both human-readable and machine-readable output.
630#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
631pub enum OutputFormat {
632    /// Human-readable text output (default).
633    Human,
634    /// Machine-readable JSON output.
635    Json,
636}
637
638/// Supported CLI subcommands.
639#[derive(Subcommand)]
640pub enum Commands {
641    /// Apply a TOML preset to an image
642    #[command(group = clap::ArgGroup::new("preset_source").required(true))]
643    Apply {
644        /// Input image path
645        #[arg(short, long)]
646        input: PathBuf,
647        /// Preset TOML file path (single preset, full replacement)
648        #[arg(short, long, group = "preset_source")]
649        preset: Option<PathBuf>,
650        /// Preset TOML files to layer (left-to-right, last-write-wins)
651        #[arg(long, group = "preset_source", num_args = 1..)]
652        presets: Vec<PathBuf>,
653        /// Output image path
654        #[arg(short, long)]
655        output: PathBuf,
656
657        /// Shared output encoding options.
658        #[command(flatten)]
659        output_opts: OutputOpts,
660    },
661    /// Edit an image with inline parameters
662    Edit {
663        /// Input image path
664        #[arg(short, long)]
665        input: PathBuf,
666        /// Output image path
667        #[arg(short, long)]
668        output: PathBuf,
669
670        /// Inline edit parameters.
671        #[command(flatten)]
672        edit: EditArgs,
673        /// Shared output encoding options.
674        #[command(flatten)]
675        output_opts: OutputOpts,
676    },
677    /// Apply a TOML preset to all images in a directory
678    BatchApply {
679        /// Preset TOML file path
680        #[arg(short, long)]
681        preset: PathBuf,
682
683        /// Shared batch processing options.
684        #[command(flatten)]
685        batch: BatchOpts,
686    },
687    /// Edit all images in a directory with inline parameters
688    BatchEdit {
689        /// Inline edit parameters.
690        #[command(flatten)]
691        edit: EditArgs,
692        /// Shared batch processing options.
693        #[command(flatten)]
694        batch: BatchOpts,
695    },
696    /// Apply multiple presets to a single image (decode once, render per preset)
697    MultiApply {
698        /// Input image path
699        #[arg(short, long)]
700        input: PathBuf,
701        /// Preset TOML file(s) to apply (one output per preset)
702        #[arg(short, long, required = true, num_args = 1..)]
703        preset: Vec<PathBuf>,
704        /// Output directory (created if missing)
705        #[arg(short, long)]
706        output: PathBuf,
707        /// Also render a no-preset (identity) output
708        #[arg(long, default_value_t = false)]
709        noop: bool,
710        /// Number of preset renders to run concurrently (default: 1)
711        #[arg(short, long, default_value_t = 1)]
712        jobs: usize,
713    },
714    /// Validate one or more preset files for correctness without rendering.
715    ///
716    /// Reports unknown fields, type mismatches, out-of-range values, missing
717    /// LUT files, and extends chain problems. Exits 0 if all clean, 1 if any
718    /// file has errors.
719    Validate {
720        /// Paths to preset TOML files. Use shell glob to validate many at once.
721        #[arg(required = true)]
722        paths: Vec<std::path::PathBuf>,
723
724        /// Suppress "ok" lines for clean files; only show files with errors.
725        #[arg(short, long)]
726        quiet: bool,
727
728        /// Output format.
729        #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
730        format: OutputFormat,
731    },
732}
733
734fn parse_output_format(s: &str) -> agx::Result<agx::encode::OutputFormat> {
735    agx::encode::OutputFormat::from_extension(s).ok_or_else(|| {
736        agx::AgxError::Encode(format!(
737            "unsupported output format '{s}'. Use: jpeg, png, or tiff"
738        ))
739    })
740}
741
742/// Build the fully-configured clap command for `agx`.
743pub fn build_cli() -> clap::Command {
744    Cli::command()
745}
746
747#[cfg(test)]
748mod tests {
749    use clap::Parser;
750
751    use super::{build_cli, Cli, Commands};
752
753    #[test]
754    fn build_cli_returns_valid_command() {
755        let command = build_cli();
756
757        command.clone().debug_assert();
758
759        assert_eq!(command.get_name(), "agx");
760
761        let subcommands: Vec<_> = command
762            .get_subcommands()
763            .map(|subcommand| subcommand.get_name().to_string())
764            .collect();
765
766        assert!(subcommands.iter().any(|name| name == "apply"));
767        assert!(subcommands.iter().any(|name| name == "edit"));
768        assert!(subcommands.iter().any(|name| name == "batch-apply"));
769        assert!(subcommands.iter().any(|name| name == "batch-edit"));
770        assert!(subcommands.iter().any(|name| name == "multi-apply"));
771    }
772
773    #[test]
774    fn output_gamut_flag_parses_into_encode_options() {
775        use super::{Cli, Commands};
776        let cli = Cli::parse_from([
777            "agx",
778            "apply",
779            "-i",
780            "in.png",
781            "-p",
782            "look.toml",
783            "-o",
784            "out.png",
785            "--output-gamut",
786            "p3",
787        ]);
788        let Commands::Apply { output_opts, .. } = cli.command else {
789            panic!("expected apply");
790        };
791        let opts = output_opts.encode_options().unwrap();
792        assert_eq!(opts.output_gamut, agx::encode::OutputGamut::DisplayP3);
793    }
794
795    #[test]
796    fn output_gamut_defaults_to_srgb() {
797        use super::{Cli, Commands};
798        let cli = Cli::parse_from([
799            "agx",
800            "apply",
801            "-i",
802            "in.png",
803            "-p",
804            "look.toml",
805            "-o",
806            "out.png",
807        ]);
808        let Commands::Apply { output_opts, .. } = cli.command else {
809            panic!("expected apply");
810        };
811        assert_eq!(
812            output_opts.encode_options().unwrap().output_gamut,
813            agx::encode::OutputGamut::Srgb
814        );
815    }
816
817    #[test]
818    fn edit_to_params_returns_error_for_invalid_tone_curve() {
819        let cli = Cli::parse_from([
820            "agx",
821            "edit",
822            "--input",
823            "input.png",
824            "--output",
825            "output.png",
826            "--tc-rgb",
827            "not-a-curve",
828        ]);
829
830        let Commands::Edit { edit, .. } = cli.command else {
831            panic!("expected edit command");
832        };
833
834        let error = edit.to_params().unwrap_err();
835
836        assert!(error.to_string().contains("Error parsing tone curve"));
837    }
838}