nooise 1.1.0

Ambient music generator for the terminal
use std::env;
use std::error::Error;
use std::path::PathBuf;
use std::process::Command;

mod audio;
mod fluid;
mod fx;
mod synth;

fn main() -> Result<(), Box<dyn Error>> {
    match parse_args(env::args().skip(1))? {
        CliCommand::Run => fluid::run(),
        CliCommand::Update => update_nooise(),
        CliCommand::Render(args) => fluid::render_wav(args.seconds, &args.out, args.seed),
    }
}

#[derive(Debug, Clone, PartialEq)]
enum CliCommand {
    Run,
    Update,
    Render(RenderArgs),
}

#[derive(Debug, Clone, PartialEq)]
struct RenderArgs {
    seconds: f32,
    out: PathBuf,
    seed: Option<u64>,
}

fn parse_args<I>(mut args: I) -> Result<CliCommand, Box<dyn Error>>
where
    I: Iterator<Item = String>,
{
    let Some(arg) = args.next() else {
        return Ok(CliCommand::Run);
    };

    match arg.as_str() {
        "-h" | "--help" => {
            print_usage();
            std::process::exit(0);
        }
        "update" | "upgrade" => {
            if let Some(extra) = args.next() {
                return Err(format!("unexpected argument after {arg}: {extra}").into());
            }
            Ok(CliCommand::Update)
        }
        "render" => parse_render_args(args),
        other => Err(format!("unknown argument: {other}").into()),
    }
}

fn parse_render_args<I>(mut args: I) -> Result<CliCommand, Box<dyn Error>>
where
    I: Iterator<Item = String>,
{
    let mut render = RenderArgs {
        seconds: 20.0,
        out: PathBuf::from("nooise.wav"),
        seed: None,
    };

    while let Some(flag) = args.next() {
        match flag.as_str() {
            "--seconds" => {
                let v = args.next().ok_or("--seconds requires a value")?;
                render.seconds = v
                    .parse()
                    .map_err(|e| format!("invalid --seconds {v}: {e}"))?;
            }
            "--out" => {
                render.out = PathBuf::from(args.next().ok_or("--out requires a path")?);
            }
            "--seed" => {
                let v = args.next().ok_or("--seed requires a value")?;
                render.seed = Some(v.parse().map_err(|e| format!("invalid --seed {v}: {e}"))?);
            }
            other => return Err(format!("unknown render flag: {other}").into()),
        }
    }

    if render.seconds <= 0.0 {
        return Err("--seconds must be positive".into());
    }
    Ok(CliCommand::Render(render))
}

fn print_usage() {
    println!("Usage: nooise                                     run the TUI");
    println!("       nooise update|upgrade                      update from crates.io");
    println!("       nooise render [--seconds N] [--out PATH] [--seed N]");
    println!("                                                  render the default mix to a wav");
}

fn update_nooise() -> Result<(), Box<dyn Error>> {
    println!("Updating nooise from crates.io...");
    let status = Command::new("cargo")
        .args(["install", "nooise", "--locked", "--force"])
        .status()
        .map_err(|e| format!("failed to run cargo install nooise: {e}"))?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("cargo install nooise failed with {status}").into())
    }
}

#[cfg(test)]
mod tests {
    use super::{CliCommand, RenderArgs, parse_args};
    use std::path::PathBuf;

    fn args(items: &[&str]) -> impl Iterator<Item = String> {
        items
            .iter()
            .map(|item| item.to_string())
            .collect::<Vec<_>>()
            .into_iter()
    }

    #[test]
    fn no_args_runs_app() {
        assert_eq!(parse_args(args(&[])).unwrap(), CliCommand::Run);
    }

    #[test]
    fn update_and_upgrade_run_updater() {
        assert_eq!(parse_args(args(&["update"])).unwrap(), CliCommand::Update);
        assert_eq!(parse_args(args(&["upgrade"])).unwrap(), CliCommand::Update);
    }

    #[test]
    fn unknown_arg_errors() {
        assert!(parse_args(args(&["--experiment"])).is_err());
    }

    #[test]
    fn render_defaults_and_flags_parse() {
        assert_eq!(
            parse_args(args(&["render"])).unwrap(),
            CliCommand::Render(RenderArgs {
                seconds: 20.0,
                out: PathBuf::from("nooise.wav"),
                seed: None,
            })
        );
        assert_eq!(
            parse_args(args(&[
                "render",
                "--seconds",
                "3.5",
                "--out",
                "/tmp/x.wav",
                "--seed",
                "42"
            ]))
            .unwrap(),
            CliCommand::Render(RenderArgs {
                seconds: 3.5,
                out: PathBuf::from("/tmp/x.wav"),
                seed: Some(42),
            })
        );
    }

    #[test]
    fn render_rejects_bad_input() {
        assert!(parse_args(args(&["render", "--seconds"])).is_err());
        assert!(parse_args(args(&["render", "--seconds", "0"])).is_err());
        assert!(parse_args(args(&["render", "--seconds", "abc"])).is_err());
        assert!(parse_args(args(&["render", "--loud"])).is_err());
        assert!(parse_args(args(&["update", "extra"])).is_err());
    }
}