rusty-pv 0.1.0

Pipe viewer — a Rust port of Andrew Wood's `pv(1)` with progress bar, ETA, rate display, token-bucket rate limiting, IEC/SI unit math, SIGWINCH-aware terminal redraw, SIGUSR1 size refresh, multi-instance cursor coordination, and a typed library API.
Documentation
//! Hand-rolled Strict-mode argv parser (AD-008, FR-039..FR-041).
//!
//! - Last-wins on repeated flags (FR-040)
//! - Grouped short flags (`-rv` = `-r -v`)
//! - Scope-bounded "invalid option" for excluded flags (`-F`/`-T`/`-A`/etc.)
//! - `completions` token is treated as an unknown positional (FR-041)

/// Parsed Strict-mode arguments (subset; v0.1.0 documented surface only).
#[derive(Debug, Default, PartialEq)]
pub struct StrictArgs {
    /// `-p` progress bar.
    pub progress: bool,
    /// `-t` elapsed timer.
    pub timer: bool,
    /// `-e` ETA.
    pub eta: bool,
    /// `-I` ETA as wall-clock arrival time.
    pub fineta: bool,
    /// `-r` instantaneous rate.
    pub rate: bool,
    /// `-b` bytes transferred.
    pub bytes: bool,
    /// `-a` average rate.
    pub average_rate: bool,
    /// `-n` numeric percentage mode.
    pub numeric: bool,
    /// `-q` quiet.
    pub quiet: bool,
    /// `-W` wait-for-first-byte.
    pub wait: bool,
    /// `-l` line mode.
    pub line_mode: bool,
    /// `-0` null mode.
    pub null_mode: bool,
    /// `-f` force display.
    pub force: bool,
    /// `-E` skip read errors.
    pub skip_errors: bool,
    /// `-c` cursor mode.
    pub cursor: bool,
    /// `--si` SI decimal units.
    pub si_units: bool,
    /// `-D SEC` delay-start.
    pub delay_start: Option<f64>,
    /// `-i SEC` interval.
    pub interval: Option<f64>,
    /// `-N NAME` name prefix.
    pub name: Option<String>,
    /// `-L RATE` rate limit (parsed string; conversion to bytes happens later).
    pub rate_limit: Option<String>,
    /// `-B BYTES` buffer size.
    pub buffer_size: Option<String>,
    /// `-s SIZE` size hint.
    pub size: Option<String>,
    /// `-w WIDTH` width override.
    pub width: Option<u16>,
    /// `-H HEIGHT` height override.
    pub height: Option<u16>,
    /// Positional input paths.
    pub paths: Vec<String>,
    /// `-h` help.
    pub help: bool,
    /// `-V` version.
    pub version: bool,
}

/// Errors from Strict-mode argv parsing.
#[derive(Debug, PartialEq, Eq)]
pub enum StrictParseError {
    /// Unknown short flag (incl. excluded `-F`/`-T`/`-A` per FR-039).
    UnknownFlag(char),
    /// Unknown long flag.
    UnknownLongFlag(String),
    /// Flag requires an argument but none was provided.
    MissingArgument(String),
    /// Numeric argument could not be parsed.
    InvalidNumber {
        /// The flag the value belongs to (e.g., `"D"`, `"i"`).
        flag: String,
        /// The unparseable input.
        value: String,
    },
}

impl std::fmt::Display for StrictParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StrictParseError::UnknownFlag(c) => {
                write!(f, "rusty-pv: invalid option -- '{c}'")
            }
            StrictParseError::UnknownLongFlag(s) => {
                write!(f, "rusty-pv: unrecognized option '--{s}'")
            }
            StrictParseError::MissingArgument(s) => {
                write!(f, "rusty-pv: option requires an argument -- '{s}'")
            }
            StrictParseError::InvalidNumber { flag, value } => {
                write!(f, "rusty-pv: invalid number for -{flag}: '{value}'")
            }
        }
    }
}

impl std::error::Error for StrictParseError {}

/// Parse Strict-mode argv (argv[0] excluded).
///
/// # Errors
///
/// Returns [`StrictParseError`] on unknown flag, missing argument, or
/// invalid numeric value.
pub fn parse(args: &[String]) -> Result<StrictArgs, StrictParseError> {
    let mut out = StrictArgs::default();
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        if arg == "--" {
            out.paths.extend(args[i + 1..].iter().cloned());
            break;
        } else if let Some(long) = arg.strip_prefix("--") {
            match long {
                "progress" => out.progress = true,
                "timer" => out.timer = true,
                "eta" => out.eta = true,
                "fineta" => out.fineta = true,
                "rate" => out.rate = true,
                "bytes" => out.bytes = true,
                "average-rate" => out.average_rate = true,
                "numeric" => out.numeric = true,
                "quiet" => out.quiet = true,
                "wait" => out.wait = true,
                "line-mode" => out.line_mode = true,
                "null-mode" => out.null_mode = true,
                "force" => out.force = true,
                "skip-errors" => out.skip_errors = true,
                "cursor" => out.cursor = true,
                "si" => out.si_units = true,
                "help" => out.help = true,
                "version" => out.version = true,
                "strict" | "no-strict" => {} // consumed by mode::resolve
                "delay-start" => {
                    let v = take_arg(args, &mut i, "delay-start")?;
                    out.delay_start = Some(parse_number("delay-start", &v)?);
                }
                "interval" => {
                    let v = take_arg(args, &mut i, "interval")?;
                    out.interval = Some(parse_number("interval", &v)?);
                }
                "name" => {
                    out.name = Some(take_arg(args, &mut i, "name")?);
                }
                "rate-limit" => {
                    out.rate_limit = Some(take_arg(args, &mut i, "rate-limit")?);
                }
                "buffer-size" => {
                    out.buffer_size = Some(take_arg(args, &mut i, "buffer-size")?);
                }
                "size" => {
                    out.size = Some(take_arg(args, &mut i, "size")?);
                }
                "width" => {
                    let v = take_arg(args, &mut i, "width")?;
                    out.width = Some(parse_u16("width", &v)?);
                }
                "height" => {
                    let v = take_arg(args, &mut i, "height")?;
                    out.height = Some(parse_u16("height", &v)?);
                }
                _ => return Err(StrictParseError::UnknownLongFlag(long.to_string())),
            }
        } else if let Some(shorts) = arg.strip_prefix('-') {
            if shorts.is_empty() {
                out.paths.push(arg.clone());
            } else {
                let mut chars = shorts.chars();
                while let Some(c) = chars.next() {
                    match c {
                        'p' => out.progress = true,
                        't' => out.timer = true,
                        'e' => out.eta = true,
                        'I' => out.fineta = true,
                        'r' => out.rate = true,
                        'b' => out.bytes = true,
                        'a' => out.average_rate = true,
                        'n' => out.numeric = true,
                        'q' => out.quiet = true,
                        'W' => out.wait = true,
                        'l' => out.line_mode = true,
                        '0' => out.null_mode = true,
                        'f' => out.force = true,
                        'E' => out.skip_errors = true,
                        'c' => out.cursor = true,
                        'h' => out.help = true,
                        'V' => out.version = true,
                        'D' => {
                            let rest: String = chars.clone().collect();
                            let v = if rest.is_empty() {
                                take_arg(args, &mut i, "D")?
                            } else {
                                rest
                            };
                            out.delay_start = Some(parse_number("D", &v)?);
                            break;
                        }
                        'i' => {
                            let rest: String = chars.clone().collect();
                            let v = if rest.is_empty() {
                                take_arg(args, &mut i, "i")?
                            } else {
                                rest
                            };
                            out.interval = Some(parse_number("i", &v)?);
                            break;
                        }
                        'N' => {
                            let rest: String = chars.clone().collect();
                            out.name = Some(if rest.is_empty() {
                                take_arg(args, &mut i, "N")?
                            } else {
                                rest
                            });
                            break;
                        }
                        'L' => {
                            let rest: String = chars.clone().collect();
                            out.rate_limit = Some(if rest.is_empty() {
                                take_arg(args, &mut i, "L")?
                            } else {
                                rest
                            });
                            break;
                        }
                        'B' => {
                            let rest: String = chars.clone().collect();
                            out.buffer_size = Some(if rest.is_empty() {
                                take_arg(args, &mut i, "B")?
                            } else {
                                rest
                            });
                            break;
                        }
                        's' => {
                            let rest: String = chars.clone().collect();
                            out.size = Some(if rest.is_empty() {
                                take_arg(args, &mut i, "s")?
                            } else {
                                rest
                            });
                            break;
                        }
                        'w' => {
                            let rest: String = chars.clone().collect();
                            let v = if rest.is_empty() {
                                take_arg(args, &mut i, "w")?
                            } else {
                                rest
                            };
                            out.width = Some(parse_u16("w", &v)?);
                            break;
                        }
                        'H' => {
                            let rest: String = chars.clone().collect();
                            let v = if rest.is_empty() {
                                take_arg(args, &mut i, "H")?
                            } else {
                                rest
                            };
                            out.height = Some(parse_u16("H", &v)?);
                            break;
                        }
                        // Excluded flags emit upstream-style "invalid option"
                        // per FR-039 + Clarifications Q12.
                        unk => return Err(StrictParseError::UnknownFlag(unk)),
                    }
                }
            }
        } else {
            // Positional path. `completions` ends up here (FR-041).
            out.paths.push(arg.clone());
        }
        i += 1;
    }
    Ok(out)
}

fn take_arg(args: &[String], i: &mut usize, flag: &str) -> Result<String, StrictParseError> {
    *i += 1;
    args.get(*i)
        .cloned()
        .ok_or_else(|| StrictParseError::MissingArgument(flag.to_string()))
}

fn parse_number(flag: &str, value: &str) -> Result<f64, StrictParseError> {
    value.parse().map_err(|_| StrictParseError::InvalidNumber {
        flag: flag.to_string(),
        value: value.to_string(),
    })
}

fn parse_u16(flag: &str, value: &str) -> Result<u16, StrictParseError> {
    value.parse().map_err(|_| StrictParseError::InvalidNumber {
        flag: flag.to_string(),
        value: value.to_string(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grouped_short_flags() {
        let p = parse(&["-pteb".into()]).unwrap();
        assert!(p.progress && p.timer && p.eta && p.bytes);
    }

    #[test]
    fn last_wins_rate_limit() {
        let p = parse(&["-L".into(), "1M".into(), "-L".into(), "5M".into()]).unwrap();
        assert_eq!(p.rate_limit.as_deref(), Some("5M"));
    }

    #[test]
    fn glued_short_flag_with_arg() {
        let p = parse(&["-Lutf_8M".into()]).unwrap();
        assert_eq!(p.rate_limit.as_deref(), Some("utf_8M"));
    }

    #[test]
    fn unknown_short_flag_rejected() {
        let err = parse(&["-Z".into()]).unwrap_err();
        assert_eq!(err, StrictParseError::UnknownFlag('Z'));
    }

    #[test]
    fn excluded_flag_f_rejected() {
        // -F is upstream-supported but excluded from v0.1.0 per FR-039.
        let err = parse(&["-F".into(), "%p".into()]).unwrap_err();
        assert_eq!(err, StrictParseError::UnknownFlag('F'));
    }

    #[test]
    fn excluded_long_flag_bits_rejected() {
        let err = parse(&["--bits".into()]).unwrap_err();
        assert_eq!(err, StrictParseError::UnknownLongFlag("bits".into()));
    }

    #[test]
    fn completions_token_is_a_positional_path() {
        let p = parse(&["completions".into(), "bash".into()]).unwrap();
        assert_eq!(p.paths, vec!["completions", "bash"]);
    }

    #[test]
    fn unknown_flag_message_format() {
        let err = StrictParseError::UnknownFlag('F');
        assert_eq!(err.to_string(), "rusty-pv: invalid option -- 'F'");
    }
}