ph-haptics 0.1.0

Host-compiled haptics DSL and no-std, no-alloc scheduling runtime modeling ERM and LRA motors using ph-curves
Documentation
use crate::ident::validate_rust_path;
use clap::{Parser, error::ErrorKind};
use std::path::PathBuf;

#[derive(Parser, Clone, Debug, Eq, PartialEq)]
#[command(name = "ph-haptics-gen")]
#[command(version)]
#[command(about = "Compile .phh haptics definitions into Rust constants")]
pub(crate) struct Cli {
    /// Input .phh file.
    #[arg(long, value_name = "PATH")]
    pub(crate) input: PathBuf,
    /// Output Rust file.
    #[arg(long, value_name = "PATH")]
    pub(crate) output: PathBuf,
    /// Optional TOML profile overlay (merged over built-in library profiles).
    #[arg(long, value_name = "PATH")]
    pub(crate) profiles_toml: Option<PathBuf>,
    /// Curve module path in generated Rust.
    #[arg(long, default_value = "crate::curves")]
    pub(crate) curves_module: String,
    /// ph-haptics path in generated Rust.
    #[arg(long, default_value = "ph_haptics")]
    pub(crate) haptics_crate: String,
    /// Optional generated curves.rs path for symbol validation.
    #[arg(long, value_name = "PATH")]
    pub(crate) curves_file: Option<PathBuf>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CliAction {
    Run(Cli),
    Help(String),
}

pub(crate) fn parse_cli(args: impl Iterator<Item = String>) -> Result<CliAction, String> {
    let mut argv = vec![String::from("ph-haptics-gen")];
    argv.extend(args);

    match Cli::try_parse_from(argv) {
        Ok(cli) => {
            validate_cli_paths(&cli)?;
            Ok(CliAction::Run(cli))
        }
        Err(error) => match error.kind() {
            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
                Ok(CliAction::Help(error.to_string()))
            }
            _ => Err(error.to_string()),
        },
    }
}

fn validate_cli_paths(cli: &Cli) -> Result<(), String> {
    validate_rust_path(&cli.curves_module, "--curves-module")?;
    validate_rust_path(&cli.haptics_crate, "--haptics-crate")?;
    Ok(())
}

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

    #[test]
    fn parse_help_flag_returns_help_action() {
        let action = parse_cli(["--help".to_owned()].into_iter()).unwrap();
        match action {
            CliAction::Help(help) => assert!(help.contains("Usage:")),
            CliAction::Run(_) => panic!("expected help action"),
        }
    }

    #[test]
    fn parse_minimal_required_args() {
        let action = parse_cli(
            [
                "--input".to_owned(),
                "haptics.phh".to_owned(),
                "--output".to_owned(),
                "haptics_compiled.rs".to_owned(),
            ]
            .into_iter(),
        )
        .unwrap();

        let CliAction::Run(cli) = action else {
            panic!("expected run action");
        };
        assert_eq!(cli.input, PathBuf::from("haptics.phh"));
        assert_eq!(cli.output, PathBuf::from("haptics_compiled.rs"));
        assert_eq!(cli.curves_module, "crate::curves");
        assert_eq!(cli.haptics_crate, "ph_haptics");
    }

    #[test]
    fn rejects_invalid_curves_module_path() {
        let err = parse_cli(
            [
                "--input".to_owned(),
                "haptics.phh".to_owned(),
                "--output".to_owned(),
                "out.rs".to_owned(),
                "--curves-module".to_owned(),
                "crate/curves".to_owned(),
            ]
            .into_iter(),
        )
        .unwrap_err();
        assert!(err.contains("--curves-module"), "error: {err}");
    }

    #[test]
    fn rejects_invalid_haptics_crate_path() {
        let err = parse_cli(
            [
                "--input".to_owned(),
                "haptics.phh".to_owned(),
                "--output".to_owned(),
                "out.rs".to_owned(),
                "--haptics-crate".to_owned(),
                "ph-haptics".to_owned(),
            ]
            .into_iter(),
        )
        .unwrap_err();
        assert!(err.contains("--haptics-crate"), "error: {err}");
    }
}