brightwheel 0.1.2

Windows tray utility for DDC/CI monitor brightness and HDR control
use std::error::Error;

#[derive(Debug, Eq, PartialEq)]
enum Command {
    List,
    Get { index: usize },
    Set { percent: u32, index: usize },
    Change { delta: i32, index: usize },
    Capabilities { index: usize },
    Hdr,
    ToggleHdr,
    Help,
    Version,
}

pub(crate) fn run(args: impl IntoIterator<Item = String>) -> Result<(), Box<dyn Error>> {
    match parse(args)? {
        Command::List => {
            for monitor in brightwheel::list()? {
                let role = if monitor.primary {
                    "primary"
                } else {
                    "secondary"
                };
                match monitor.brightness {
                    Ok(value) => println!(
                        "{}\t{}\t{}\t{}/{}\t{}%",
                        monitor.index,
                        role,
                        monitor.description,
                        value.current,
                        value.maximum,
                        value.percent()
                    ),
                    Err(error) => println!(
                        "{}\t{}\t{}\tunsupported\t{}",
                        monitor.index, role, monitor.description, error
                    ),
                }
            }
        }
        Command::Get { index } => println!("{}", brightwheel::get(index)?.percent()),
        Command::Set { percent, index } => {
            println!("{}", brightwheel::set(index, percent)?.percent());
        }
        Command::Change { delta, index } => {
            println!("{}", brightwheel::change(index, delta)?.percent());
        }
        Command::Capabilities { index } => println!("{}", brightwheel::capabilities(index)?),
        Command::Hdr => print_hdr_state(brightwheel::hdr::state()?.enabled),
        Command::ToggleHdr => print_hdr_state(brightwheel::hdr::toggle()?.enabled),
        Command::Help => println!("{USAGE}"),
        Command::Version => println!("bright {}", env!("CARGO_PKG_VERSION")),
    }
    Ok(())
}

fn parse(args: impl IntoIterator<Item = String>) -> Result<Command, String> {
    let mut args = args.into_iter();
    let command = args.next().unwrap_or_else(|| "help".to_owned());

    match command.as_str() {
        "list" => {
            ensure_no_extra(args)?;
            Ok(Command::List)
        }
        "get" => {
            let index = optional_index(&mut args)?;
            ensure_no_extra(args)?;
            Ok(Command::Get { index })
        }
        "set" => {
            let percent = required_u32(&mut args, "PERCENT")?;
            let index = optional_index(&mut args)?;
            ensure_no_extra(args)?;
            Ok(Command::Set { percent, index })
        }
        "change" => {
            let delta = required_i32(&mut args, "DELTA")?;
            let index = optional_index(&mut args)?;
            ensure_no_extra(args)?;
            Ok(Command::Change { delta, index })
        }
        "capabilities" | "caps" => {
            let index = optional_index(&mut args)?;
            ensure_no_extra(args)?;
            Ok(Command::Capabilities { index })
        }
        "hdr" => {
            ensure_no_extra(args)?;
            Ok(Command::Hdr)
        }
        "hdr-toggle" => {
            ensure_no_extra(args)?;
            Ok(Command::ToggleHdr)
        }
        "help" | "--help" | "-h" => Ok(Command::Help),
        "version" | "--version" | "-V" => Ok(Command::Version),
        _ => Err(format!("unknown command '{command}'\n\n{USAGE}")),
    }
}

fn optional_index(args: &mut impl Iterator<Item = String>) -> Result<usize, String> {
    args.next().map_or(Ok(0), |value| {
        value
            .parse()
            .map_err(|_| format!("invalid monitor index '{value}'"))
    })
}

fn required_u32(args: &mut impl Iterator<Item = String>, name: &str) -> Result<u32, String> {
    parse_required(args, name)
}

fn required_i32(args: &mut impl Iterator<Item = String>, name: &str) -> Result<i32, String> {
    parse_required(args, name)
}

fn parse_required<T>(args: &mut impl Iterator<Item = String>, name: &str) -> Result<T, String>
where
    T: std::str::FromStr,
{
    let value = args.next().ok_or_else(|| format!("missing {name}"))?;
    value
        .parse()
        .map_err(|_| format!("invalid {name} '{value}'"))
}

fn ensure_no_extra(mut args: impl Iterator<Item = String>) -> Result<(), String> {
    args.next().map_or(Ok(()), |value| {
        Err(format!("unexpected argument '{value}'"))
    })
}

fn print_hdr_state(enabled: bool) {
    println!("{}", if enabled { "on" } else { "off" });
}

const USAGE: &str = "Usage:
  bright list
  bright get [MONITOR]
  bright set PERCENT [MONITOR]
  bright change DELTA [MONITOR]
  bright capabilities [MONITOR]
  bright hdr
  bright hdr-toggle

PERCENT is 0..100. MONITOR defaults to 0.";

#[cfg(test)]
mod tests {
    use super::{Command, USAGE, parse};

    fn args<'a>(values: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
        values.iter().map(ToString::to_string)
    }

    #[test]
    fn defaults_to_help() {
        assert_eq!(parse(args(&[])), Ok(Command::Help));
    }

    #[test]
    fn parses_commands_and_default_monitor() {
        assert_eq!(parse(args(&["list"])), Ok(Command::List));
        assert_eq!(parse(args(&["get"])), Ok(Command::Get { index: 0 }));
        assert_eq!(
            parse(args(&["set", "77"])),
            Ok(Command::Set {
                percent: 77,
                index: 0
            })
        );
        assert_eq!(
            parse(args(&["change", "-5"])),
            Ok(Command::Change {
                delta: -5,
                index: 0
            })
        );
    }

    #[test]
    fn parses_explicit_monitor_indexes_and_aliases() {
        assert_eq!(parse(args(&["get", "2"])), Ok(Command::Get { index: 2 }));
        assert_eq!(
            parse(args(&["caps", "3"])),
            Ok(Command::Capabilities { index: 3 })
        );
        assert_eq!(parse(args(&["hdr"])), Ok(Command::Hdr));
        assert_eq!(parse(args(&["hdr-toggle"])), Ok(Command::ToggleHdr));
    }

    #[test]
    fn parses_help_and_version_aliases() {
        for alias in ["help", "--help", "-h"] {
            assert_eq!(parse(args(&[alias])), Ok(Command::Help));
        }
        for alias in ["version", "--version", "-V"] {
            assert_eq!(parse(args(&[alias])), Ok(Command::Version));
        }
    }

    #[test]
    fn reports_missing_and_invalid_values() {
        assert_eq!(parse(args(&["set"])), Err("missing PERCENT".to_owned()));
        assert_eq!(
            parse(args(&["set", "bright"])),
            Err("invalid PERCENT 'bright'".to_owned())
        );
        assert_eq!(
            parse(args(&["change", "up"])),
            Err("invalid DELTA 'up'".to_owned())
        );
        assert_eq!(
            parse(args(&["get", "primary"])),
            Err("invalid monitor index 'primary'".to_owned())
        );
    }

    #[test]
    fn rejects_unexpected_arguments() {
        assert_eq!(
            parse(args(&["list", "1"])),
            Err("unexpected argument '1'".to_owned())
        );
        assert_eq!(
            parse(args(&["set", "50", "0", "extra"])),
            Err("unexpected argument 'extra'".to_owned())
        );
    }

    #[test]
    fn unknown_command_includes_usage() {
        assert_eq!(
            parse(args(&["wat"])),
            Err(format!("unknown command 'wat'\n\n{USAGE}"))
        );
    }
}