Skip to main content

rusty_figlet/
strict.rs

1//! Hand-rolled Strict-mode argv parser per AD-007.
2//!
3//! Mirrors upstream `figlet(6)` getopt diagnostics byte-for-byte (modulo
4//! the `figlet:` → `rusty-figlet:` program-name substitution applied by
5//! the test harness): excluded short flags surface
6//! `figlet: invalid option -- '<char>'`; excluded long flags surface
7//! `figlet: unrecognized option '<flag>'`.
8//!
9//! Last-wins semantics for repeated flags (`-c`/`-l`/`-r`, layout flags,
10//! `-w`) per FR-022 + FR-023.
11
12use std::ffi::OsString;
13use std::path::PathBuf;
14
15use crate::error::FigletError;
16
17/// Set of single-letter flags that take a following argument.
18const ARG_TAKING_SHORTS: &[char] = &['f', 'd', 'w', 'm', 'C', 'I'];
19
20/// Excluded (forbidden) short flags in Strict mode per FR-042 + FR-046.
21const EXCLUDED_SHORTS: &[char] = &['L', 'R', 'I', 'N', 'C'];
22
23/// Excluded long flags in Strict mode per FR-043 + FR-045.
24const EXCLUDED_LONGS: &[&str] = &[
25    "--info-dump",
26    "--no-controlfile",
27    "--color",
28    "--rainbow",
29    "--left-to-right",
30    "--right-to-left",
31];
32
33/// Outcome of [`parse_argv`] on success — the resolved argument bag.
34#[derive(Debug, Default, Clone)]
35pub struct StrictArgs {
36    /// Resolved font (last-wins).
37    pub font: Option<String>,
38    /// Repeated `-d` font dirs.
39    pub font_dirs: Vec<PathBuf>,
40    /// Resolved width (last-wins).
41    pub width: Option<u32>,
42    /// `-t` flag set.
43    pub use_terminal_width: bool,
44    /// Resolved justify (last-wins between `-c`/`-l`/`-r`/`-x`).
45    pub justify: Option<JustifyKind>,
46    /// Resolved layout (last-wins between `-k`/`-W`/`-S`/`-s`/`-o`/`-m`).
47    pub layout: Option<LayoutKind>,
48    /// Paragraph (`-p`) or normal (`-n`) mode flag — last-wins.
49    pub paragraph: Option<bool>,
50    /// Positional message tokens, concatenated with single space at
51    /// render time per FR-002.
52    pub message: Vec<String>,
53}
54
55/// Resolved justify-class flag for Strict mode.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum JustifyKind {
58    /// `-c`
59    Center,
60    /// `-l`
61    Left,
62    /// `-r`
63    Right,
64    /// `-x`
65    FontDefault,
66}
67
68/// Resolved layout-class flag for Strict mode.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum LayoutKind {
71    /// `-k`
72    Kerning,
73    /// `-W`
74    FullWidth,
75    /// `-S`
76    ForceSmush,
77    /// `-s`
78    DefaultSmush,
79    /// `-o`
80    OverlapOnly,
81    /// `-m N`
82    Explicit(i32),
83}
84
85/// Strict-mode parse error. Carries the formatted, byte-equal upstream
86/// diagnostic for emission on stderr.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum StrictError {
89    /// Excluded short flag (e.g. `-L`); message is
90    /// `figlet: invalid option -- 'X'`.
91    InvalidShortOption {
92        /// The offending short-flag character.
93        ch: char,
94        /// Pre-formatted upstream-style stderr message (no trailing newline).
95        message: String,
96    },
97    /// Excluded long flag (e.g. `--info-dump`); message is
98    /// `figlet: unrecognized option '--info-dump'`.
99    UnrecognizedLongOption {
100        /// The offending long-flag token (including the leading `--`).
101        flag: String,
102        /// Pre-formatted upstream-style stderr message (no trailing newline).
103        message: String,
104    },
105    /// A short flag that takes an argument was supplied with no value.
106    MissingArgument {
107        /// The short-flag character missing its argument.
108        ch: char,
109        /// Pre-formatted upstream-style stderr message (no trailing newline).
110        message: String,
111    },
112}
113
114impl StrictError {
115    /// Return the pre-formatted upstream-style stderr message.
116    pub fn message(&self) -> &str {
117        match self {
118            Self::InvalidShortOption { message, .. }
119            | Self::UnrecognizedLongOption { message, .. }
120            | Self::MissingArgument { message, .. } => message,
121        }
122    }
123}
124
125impl From<StrictError> for FigletError {
126    fn from(err: StrictError) -> Self {
127        FigletError::Internal(match err {
128            StrictError::InvalidShortOption { .. } => "strict: invalid short option",
129            StrictError::UnrecognizedLongOption { .. } => "strict: unrecognized long option",
130            StrictError::MissingArgument { .. } => "strict: missing argument",
131        })
132    }
133}
134
135/// Format an unknown-flag diagnostic per FR-042 / FR-043.
136///
137/// `token` must include the leading `-` for short flags or `--` for
138/// long flags. Returns the byte-equal upstream string (no trailing
139/// newline). The program-name prefix is the literal `figlet:` — the
140/// test harness substitutes `rusty-figlet:` before snapshot comparison.
141pub fn format_unknown_flag(token: &str) -> String {
142    if let Some(long) = token.strip_prefix("--") {
143        format!("figlet: unrecognized option '--{long}'")
144    } else if let Some(rest) = token.strip_prefix('-') {
145        let ch = rest.chars().next().unwrap_or('?');
146        format!("figlet: invalid option -- '{ch}'")
147    } else {
148        format!("figlet: unrecognized option '{token}'")
149    }
150}
151
152/// Parse `argv` (NOT including `argv[0]`) into a [`StrictArgs`]. Stops
153/// at the first excluded/unknown flag and returns [`StrictError`] with
154/// the upstream-format diagnostic.
155pub fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, StrictError> {
156    let mut args = StrictArgs::default();
157    let mut i = 0usize;
158    // After `--`, all remaining tokens are positional.
159    let mut positional_only = false;
160
161    while i < argv.len() {
162        let token = match argv[i].to_str() {
163            Some(s) => s.to_owned(),
164            None => {
165                args.message.push(argv[i].to_string_lossy().into_owned());
166                i += 1;
167                continue;
168            }
169        };
170
171        if positional_only {
172            args.message.push(token);
173            i += 1;
174            continue;
175        }
176
177        if token == "--" {
178            positional_only = true;
179            i += 1;
180            continue;
181        }
182
183        if let Some(long) = token.strip_prefix("--") {
184            // We intentionally do NOT support any long flag in Strict
185            // mode beyond `--strict` / `--no-strict` (already consumed
186            // by mode::resolve before we get here). Every other long
187            // form is rejected with the upstream "unrecognized option"
188            // format.
189            if long == "strict" || long == "no-strict" {
190                i += 1;
191                continue;
192            }
193            // Excluded longs and any other long form are both rejected
194            // with the same upstream diagnostic.
195            let _ = EXCLUDED_LONGS;
196            return Err(StrictError::UnrecognizedLongOption {
197                flag: token.clone(),
198                message: format_unknown_flag(&token),
199            });
200        }
201
202        if let Some(short_body) = token.strip_prefix('-').filter(|s| !s.is_empty()) {
203            // Grouped shorts handled char-by-char; the first arg-taking
204            // short in a group consumes the remainder of the token as
205            // its value (or the next argv token if the group ends).
206            let chars: Vec<char> = short_body.chars().collect();
207            let mut idx = 0usize;
208            while idx < chars.len() {
209                let ch = chars[idx];
210
211                if EXCLUDED_SHORTS.contains(&ch) {
212                    let token_str = format!("-{ch}");
213                    return Err(StrictError::InvalidShortOption {
214                        ch,
215                        message: format_unknown_flag(&token_str),
216                    });
217                }
218
219                if ARG_TAKING_SHORTS.contains(&ch) {
220                    let value = if idx + 1 < chars.len() {
221                        chars[idx + 1..].iter().collect::<String>()
222                    } else {
223                        i += 1;
224                        match argv.get(i).and_then(|os| os.to_str()).map(str::to_owned) {
225                            Some(v) => v,
226                            None => {
227                                let msg = format!("figlet: option requires an argument -- '{ch}'");
228                                return Err(StrictError::MissingArgument { ch, message: msg });
229                            }
230                        }
231                    };
232                    apply_short_with_value(&mut args, ch, &value);
233                    idx = chars.len();
234                    continue;
235                }
236
237                match ch {
238                    'c' => args.justify = Some(JustifyKind::Center),
239                    'l' => args.justify = Some(JustifyKind::Left),
240                    'r' => args.justify = Some(JustifyKind::Right),
241                    'x' => args.justify = Some(JustifyKind::FontDefault),
242                    'k' => args.layout = Some(LayoutKind::Kerning),
243                    'W' => args.layout = Some(LayoutKind::FullWidth),
244                    'S' => args.layout = Some(LayoutKind::ForceSmush),
245                    's' => args.layout = Some(LayoutKind::DefaultSmush),
246                    'o' => args.layout = Some(LayoutKind::OverlapOnly),
247                    't' => args.use_terminal_width = true,
248                    'p' => args.paragraph = Some(true),
249                    'n' => args.paragraph = Some(false),
250                    other => {
251                        let token_str = format!("-{other}");
252                        return Err(StrictError::InvalidShortOption {
253                            ch: other,
254                            message: format_unknown_flag(&token_str),
255                        });
256                    }
257                }
258                idx += 1;
259            }
260            i += 1;
261            continue;
262        }
263
264        // Positional.
265        args.message.push(token);
266        i += 1;
267    }
268
269    Ok(args)
270}
271
272fn apply_short_with_value(args: &mut StrictArgs, ch: char, value: &str) {
273    match ch {
274        'f' => args.font = Some(value.to_owned()),
275        'd' => args.font_dirs.push(PathBuf::from(value)),
276        'w' => {
277            if let Ok(n) = value.parse::<u32>() {
278                args.width = Some(n);
279            }
280        }
281        'm' => {
282            if let Ok(n) = value.parse::<i32>() {
283                args.layout = Some(LayoutKind::Explicit(n));
284            }
285        }
286        // Excluded shorts that take a value are caught earlier; reach
287        // here only via the ARG_TAKING_SHORTS allow-list above.
288        _ => {}
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn parse(s: &[&str]) -> Result<StrictArgs, StrictError> {
297        let argv: Vec<OsString> = s.iter().map(|&v| OsString::from(v)).collect();
298        parse_argv(&argv)
299    }
300
301    #[test]
302    fn empty_argv_ok() {
303        let got = parse(&[]).unwrap();
304        assert!(got.message.is_empty());
305    }
306
307    #[test]
308    fn single_positional_collected() {
309        let got = parse(&["hello"]).unwrap();
310        assert_eq!(got.message, vec!["hello".to_owned()]);
311    }
312
313    #[test]
314    fn dash_f_with_separate_value() {
315        let got = parse(&["-f", "slant", "X"]).unwrap();
316        assert_eq!(got.font.as_deref(), Some("slant"));
317        assert_eq!(got.message, vec!["X".to_owned()]);
318    }
319
320    #[test]
321    fn dash_f_with_attached_value() {
322        let got = parse(&["-fslant", "X"]).unwrap();
323        assert_eq!(got.font.as_deref(), Some("slant"));
324    }
325
326    #[test]
327    #[allow(non_snake_case)]
328    fn excluded_short_L_rejected() {
329        let err = parse(&["-L", "X"]).unwrap_err();
330        match err {
331            StrictError::InvalidShortOption { ch, message } => {
332                assert_eq!(ch, 'L');
333                assert_eq!(message, "figlet: invalid option -- 'L'");
334            }
335            other => panic!("expected InvalidShortOption, got {other:?}"),
336        }
337    }
338
339    #[test]
340    #[allow(non_snake_case)]
341    fn excluded_short_C_rejected() {
342        let err = parse(&["-C", "file", "X"]).unwrap_err();
343        match err {
344            StrictError::InvalidShortOption { ch, .. } => assert_eq!(ch, 'C'),
345            other => panic!("expected InvalidShortOption, got {other:?}"),
346        }
347    }
348
349    #[test]
350    fn excluded_long_info_dump_rejected() {
351        let err = parse(&["--info-dump", "X"]).unwrap_err();
352        match err {
353            StrictError::UnrecognizedLongOption { flag, message } => {
354                assert_eq!(flag, "--info-dump");
355                assert_eq!(message, "figlet: unrecognized option '--info-dump'");
356            }
357            other => panic!("expected UnrecognizedLongOption, got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn excluded_long_color_rejected() {
363        let err = parse(&["--color=always", "X"]).unwrap_err();
364        match err {
365            StrictError::UnrecognizedLongOption { .. } => {}
366            other => panic!("expected UnrecognizedLongOption, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn last_wins_justify_flags() {
372        let got = parse(&["-c", "-l", "-r", "X"]).unwrap();
373        assert_eq!(got.justify, Some(JustifyKind::Right));
374    }
375
376    #[test]
377    fn last_wins_layout_flags() {
378        let got = parse(&["-k", "-W", "-S", "X"]).unwrap();
379        assert_eq!(got.layout, Some(LayoutKind::ForceSmush));
380    }
381
382    #[test]
383    fn double_dash_makes_rest_positional() {
384        let got = parse(&["--", "-S", "-f"]).unwrap();
385        assert_eq!(got.message, vec!["-S".to_owned(), "-f".to_owned()]);
386    }
387
388    #[test]
389    fn format_unknown_flag_shapes() {
390        assert_eq!(format_unknown_flag("-L"), "figlet: invalid option -- 'L'");
391        assert_eq!(
392            format_unknown_flag("--rainbow"),
393            "figlet: unrecognized option '--rainbow'"
394        );
395    }
396}