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