ez_ffmpeg/core/cli/error.rs
1//! Typed diagnostics for the CLI-compat layer.
2//!
3//! Every rejection is anchored to the offending token (its argv index and
4//! text), explains why the token is outside the supported subset, and ends
5//! with the static call-to-action line — a rejected command must never fail
6//! silently or vaguely.
7
8/// Static call-to-action appended to every subset rejection.
9pub(crate) const CTA: &str =
10 "want this supported? open an issue: https://github.com/YeautyYE/ez-ffmpeg/issues";
11
12/// ` (token #N)` when the position is known, empty otherwise.
13fn fmt_at(index: &Option<usize>) -> String {
14 match index {
15 Some(index) => format!(" (token #{index})"),
16 None => String::new(),
17 }
18}
19
20/// Where in the command an option was found (positional scoping: options
21/// apply to the next file on the command line).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum CliScope {
25 /// An option the support table classifies as global: it applies to the
26 /// whole run wherever it appears (e.g. `-y`), so no file position is
27 /// reported for it.
28 Global,
29 /// Before `-i`: applies to the input file.
30 Input,
31 /// After the input, before the output path: applies to the output file.
32 Output,
33 /// After the output path: in ffmpeg grammar an option here would apply
34 /// to a FOLLOWING output file, which the single-output subset does not
35 /// have.
36 AfterOutput,
37}
38
39impl std::fmt::Display for CliScope {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 CliScope::Global => write!(f, "global scope"),
43 CliScope::Input => write!(f, "input #0"),
44 CliScope::Output => write!(f, "output #0"),
45 CliScope::AfterOutput => {
46 write!(f, "after output #0 (would apply to a following output)")
47 }
48 }
49 }
50}
51
52/// Error type of the CLI-compat entry points ([`from_cli_args`],
53/// [`from_cli`], [`emit_rust_code`], [`emit_rust_code_from_args`]).
54///
55/// The subset contract: every argv token must classify against the
56/// compatibility manifest or the whole command is rejected with one of these
57/// variants — the layer never guesses, approximates, or silently drops a
58/// token.
59///
60/// [`from_cli_args`]: crate::core::cli::from_cli_args
61/// [`from_cli`]: crate::core::cli::from_cli
62/// [`emit_rust_code`]: crate::core::cli::emit_rust_code
63/// [`emit_rust_code_from_args`]: crate::core::cli::emit_rust_code_from_args
64#[derive(thiserror::Error, Debug)]
65#[non_exhaustive]
66pub enum CliError {
67 /// The single-string form could not be tokenized under the documented
68 /// POSIX word-splitting contract (shell constructs, unterminated quotes,
69 /// bare newlines, trailing backslash…). `offset` is the byte offset of
70 /// the offending character in the original string.
71 #[error("tokenize error at byte {offset}: {message}\n the string form implements POSIX word splitting only — no variables, globs, tilde, pipes, redirects, comments or command lists; pass an argv slice to from_cli_args to sidestep shell quoting entirely\n {CTA}")]
72 Tokenize {
73 /// What is wrong at `offset` (e.g. `unterminated single quote`).
74 message: String,
75 /// Byte offset of the offending character in the original command
76 /// string.
77 offset: usize,
78 },
79
80 /// A token in option position did not classify into the supported option
81 /// table. `reason` explains the status (unknown, documented gap, alias,
82 /// per-stream indexed variant, …); `hint` may name the nearest supported
83 /// spelling.
84 #[error("unsupported option `{option}` (token #{index}, {scope})\n {reason}{}\n {CTA}", .hint.as_deref().map(|h| format!("\n {h}")).unwrap_or_default())]
85 UnsupportedOption {
86 /// The rejected option token, exactly as typed.
87 option: String,
88 /// Zero-based index of the option token in the parsed argv.
89 index: usize,
90 /// Where in the command the token was found.
91 scope: CliScope,
92 /// Why the spelling is outside the subset (unknown, documented gap,
93 /// alias, per-stream indexed variant, …).
94 reason: String,
95 /// Nearest supported spelling, when one is close enough to suggest.
96 hint: Option<String>,
97 },
98
99 /// The option is supported but this value form is not.
100 #[error("unsupported value `{value}` for `{option}` (token #{index})\n {reason}\n {CTA}")]
101 UnsupportedValue {
102 /// The option whose value failed its grammar.
103 option: String,
104 /// The rejected value token, verbatim.
105 value: String,
106 /// Zero-based argv index of the VALUE token (not of the option that
107 /// introduced it).
108 index: usize,
109 /// The value grammar the token violated.
110 reason: String,
111 },
112
113 /// The command's structure is outside the subset (missing/duplicated
114 /// files, non-canonical ordering, trailing tokens, …). This is a subset
115 /// limitation, not necessarily invalid ffmpeg syntax.
116 #[error("unsupported command layout at token #{index} (`{token}`)\n {reason}\n {CTA}")]
117 UnsupportedLayout {
118 /// Token at which the violation was detected; empty when the command
119 /// ended before a required token (missing input or output).
120 token: String,
121 /// Zero-based argv index of `token`; the total token count when the
122 /// command ended early.
123 index: usize,
124 /// The layout rule the command violated.
125 reason: String,
126 },
127
128 /// Two options that cannot coexist (e.g. `-t` and `-to` in the same
129 /// scope, `-map` together with `-vf`). The indexes anchor each side's
130 /// first occurrence in the argv when known.
131 #[error("conflicting options `{first}`{} and `{second}`{}\n {reason}\n {CTA}", fmt_at(.first_index), fmt_at(.second_index))]
132 ConflictingOptions {
133 /// Display name of the first participant; may carry a value
134 /// qualifier (`-c:v copy`) or be collective (`-hls_*`).
135 first: String,
136 /// Display name of the second participant (same conventions as
137 /// `first`).
138 second: String,
139 /// Argv index of `first`'s earliest participating occurrence, when
140 /// resolvable.
141 first_index: Option<usize>,
142 /// Argv index of `second`'s earliest participating occurrence, when
143 /// resolvable.
144 second_index: Option<usize>,
145 /// Why the pair cannot coexist in one command.
146 reason: String,
147 },
148
149 /// The command has no `-y`. The CLI would prompt interactively before
150 /// overwriting; this crate always opens outputs with O_CREAT|O_TRUNC and
151 /// has no prompt, so a command without `-y` cannot be run faithfully.
152 #[error("missing mandatory `-y`\n without -y the ffmpeg CLI prompts before overwriting an existing output; this library always creates/truncates the output and cannot reproduce that prompt, so the subset requires an explicit -y\n {CTA}")]
153 MissingOverwriteFlag,
154
155 /// Every token classified, but the command shape is not in the verified
156 /// set of the compatibility manifest — runtime execution is refused.
157 /// The emitters still accept this command and label their output as
158 /// unverified scaffolding.
159 #[error("command shape is not verified for execution\n parsed options: [{}]\n only shapes backed by a semantic golden may run; use emit_rust_code / emit_rust_code_from_args to generate unverified scaffolding code instead\n {CTA}", .parsed_options.join(", "))]
160 NotVerified {
161 /// The command's manifest fingerprint: sorted, scope-qualified
162 /// canonical option keys (e.g. `in:-ss`, `out:-c:v`).
163 parsed_options: Vec<String>,
164 },
165
166 /// Every token classified, but the command's option-set fingerprint
167 /// matches neither a verified shape nor a documented emit-only entry.
168 /// The manifest enumerates its emit surface explicitly — arbitrary
169 /// combinations are rejected, not silently scaffolded.
170 #[error("command shape is not in the compatibility manifest\n parsed options: [{}]\n neither a verified shape nor a documented emit-only entry; nothing is generated for unenumerated shapes\n {CTA}", .parsed_options.join(", "))]
171 UnmatchedShape {
172 /// The command's manifest fingerprint: sorted, scope-qualified
173 /// canonical option keys (e.g. `in:-ss`, `out:-c:v`).
174 parsed_options: Vec<String>,
175 },
176
177 /// A `-vf` command's source stream is not structurally unique: the
178 /// opened input carries more or fewer than exactly one video stream.
179 /// The CLI would score-select one stream and filter it; the subset runs
180 /// the filter only when no selection is involved at all (the hard
181 /// simple-filter prerequisite), so ambiguous inputs are rejected after
182 /// probing instead of silently filtering a chosen stream.
183 #[error("-vf requires an input with exactly one video stream; this input has {video_streams}\n the ffmpeg CLI would score-select one stream to filter; the subset only executes filters over a structurally unique source\n {CTA}")]
184 AmbiguousFilterSource {
185 /// Video-stream count of the opened input (anything but exactly 1).
186 video_streams: usize,
187 },
188
189 /// The linked FFmpeg libraries are not one of the verified runtime
190 /// profiles. Raised before any I/O.
191 #[error("linked FFmpeg is not a verified runtime profile\n linked: libavcodec {linked_avcodec}, libavformat {linked_avformat}; verified profiles: {verified}\n emit_rust_code still works — only in-process execution is gated\n {CTA}")]
192 UnverifiedRuntimeProfile {
193 /// `major.minor` of the linked libavcodec.
194 linked_avcodec: String,
195 /// `major.minor` of the linked libavformat.
196 linked_avformat: String,
197 /// Rendered list of the verified profiles (name plus the
198 /// libavcodec/libavformat pairs each one requires).
199 verified: String,
200 },
201
202 /// The command classified and its shape is verified, but building the
203 /// pipeline failed (I/O, codec availability, filter validation…). This
204 /// wraps the crate's own typed error.
205 #[error("building the pipeline failed: {0}")]
206 Build(
207 /// The crate's typed error from context building.
208 #[from]
209 crate::error::Error,
210 ),
211}