pipecircuit-cli 0.0.6

Command-line architectural tools for Pipecircuit programs
use std::{
    env,
    error::Error,
    ffi::OsString,
    fmt,
    io::{self, IsTerminal as _, Write as _},
    path::PathBuf,
    process::ExitCode,
};

use pipecircuit_visualization::{CrateArchitecture, DiagramTarget};

mod new;

const HELP: &str = "\
Create Pipecircuit workspaces and derive architectural diagrams.

Usage:
    pipec new <PATH> [--name <NAME>] [--framework-path <PATH>] [--template portable|native|browser]
    pipec diagram <STATE_MACHINE> [--crate <PATH>] [--target <TARGET>]

Targets:
    text       Terminal tree (default)
    mermaid    Mermaid flowchart
    graphviz   Graphviz DOT
";

#[derive(Debug, Eq, PartialEq)]
struct CliError(String);

impl CliError {
    fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

impl fmt::Display for CliError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl Error for CliError {}

enum Command {
    New(new::NewCommand),
    Diagram(DiagramCommand),
    Help,
}

struct DiagramCommand {
    crate_root: PathBuf,
    state_machine: String,
    target: DiagramTarget,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("pipec: {error}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<(), Box<dyn Error>> {
    let command = parse_arguments(env::args_os().skip(1))?;
    let ansi = io::stdout().is_terminal() && env::var_os("NO_COLOR").is_none();
    let output = execute(command, ansi)?;
    io::stdout().lock().write_all(output.as_bytes())?;
    Ok(())
}

fn parse_arguments(mut arguments: impl Iterator<Item = OsString>) -> Result<Command, CliError> {
    let Some(command) = arguments.next() else {
        return Ok(Command::Help);
    };
    let command = utf8(command, "command")?;
    match command.as_str() {
        "diagram" => parse_diagram(arguments),
        "new" => new::parse(arguments),
        "help" | "-h" | "--help" => Ok(Command::Help),
        _ => Err(CliError::new(format!(
            "unknown command `{command}`\n\n{HELP}"
        ))),
    }
}

fn parse_diagram(mut arguments: impl Iterator<Item = OsString>) -> Result<Command, CliError> {
    let mut crate_root = None;
    let mut state_machine = None;
    let mut target = None;

    while let Some(argument) = arguments.next() {
        match argument.to_str() {
            Some("--crate") => {
                let path = arguments
                    .next()
                    .ok_or_else(|| CliError::new("missing path after `--crate`"))?;
                if crate_root.replace(PathBuf::from(path)).is_some() {
                    return Err(CliError::new("`--crate` was provided more than once"));
                }
            }
            Some("--target") => {
                let value = arguments
                    .next()
                    .ok_or_else(|| CliError::new("missing value after `--target`"))?;
                let parsed = parse_target(utf8(value, "target")?)?;
                if target.replace(parsed).is_some() {
                    return Err(CliError::new("`--target` was provided more than once"));
                }
            }
            Some("help" | "-h" | "--help") => return Ok(Command::Help),
            Some(flag) if flag.starts_with('-') => {
                return Err(CliError::new(format!("unknown option `{flag}`")));
            }
            _ => {
                let name = utf8(argument, "state-machine name")?;
                if state_machine.replace(name).is_some() {
                    return Err(CliError::new(
                        "more than one state-machine name was provided",
                    ));
                }
            }
        }
    }

    let state_machine = state_machine
        .ok_or_else(|| CliError::new(format!("missing state-machine name\n\n{HELP}")))?;
    let crate_root = match crate_root {
        Some(crate_root) => crate_root,
        None => env::current_dir().map_err(|error| {
            CliError::new(format!("could not resolve current directory: {error}"))
        })?,
    };
    Ok(Command::Diagram(DiagramCommand {
        crate_root,
        state_machine,
        target: target.unwrap_or(DiagramTarget::TextTree),
    }))
}

fn utf8(argument: OsString, name: &str) -> Result<String, CliError> {
    argument
        .into_string()
        .map_err(|_| CliError::new(format!("{name} was not valid UTF-8")))
}

fn parse_target(target: String) -> Result<DiagramTarget, CliError> {
    match target.as_str() {
        "text" => Ok(DiagramTarget::TextTree),
        "mermaid" => Ok(DiagramTarget::MermaidFlowchart),
        "graphviz" => Ok(DiagramTarget::GraphvizDot),
        _ => Err(CliError::new(format!(
            "unknown diagram target `{target}`; expected `text`, `mermaid`, or `graphviz`"
        ))),
    }
}

fn execute(command: Command, ansi: bool) -> Result<String, Box<dyn Error>> {
    match command {
        Command::Help => Ok(HELP.to_owned()),
        Command::New(command) => new::create(command),
        Command::Diagram(command) => {
            let architecture = CrateArchitecture::read(command.crate_root)?;
            let diagram = if command.state_machine.contains("::") {
                architecture.state_machine_qualified(&command.state_machine)?
            } else {
                architecture.state_machine(&command.state_machine)?
            };
            let target = match (command.target, ansi) {
                (DiagramTarget::TextTree, true) => DiagramTarget::AnsiTextTree,
                (target, _) => target,
            };
            Ok(diagram.render(target))
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{env, ffi::OsString, path::PathBuf};

    use pipecircuit_visualization::DiagramTarget;

    use super::{CliError, Command, parse_arguments};

    fn arguments(values: &[&str]) -> impl Iterator<Item = OsString> {
        values.iter().map(OsString::from)
    }

    #[test]
    fn diagram_defaults_to_the_current_crate_and_text() -> Result<(), Box<dyn std::error::Error>> {
        let Command::Diagram(command) = parse_arguments(arguments(&["diagram", "Inns"]))? else {
            return Err("diagram command was not parsed".into());
        };
        assert_eq!(command.crate_root, env::current_dir()?);
        assert_eq!(command.state_machine, "Inns");
        assert_eq!(command.target, DiagramTarget::TextTree);
        Ok(())
    }

    #[test]
    fn diagram_accepts_an_explicit_crate_and_target() -> Result<(), Box<dyn std::error::Error>> {
        let Command::Diagram(command) = parse_arguments(arguments(&[
            "diagram",
            "crate::inns::Inns",
            "--crate",
            "riftopia/core",
            "--target",
            "mermaid",
        ]))?
        else {
            return Err("diagram command was not parsed".into());
        };
        assert_eq!(command.crate_root, PathBuf::from("riftopia/core"));
        assert_eq!(command.state_machine, "crate::inns::Inns");
        assert_eq!(command.target, DiagramTarget::MermaidFlowchart);
        Ok(())
    }

    #[test]
    fn diagram_rejects_ambiguous_arguments() {
        assert_eq!(
            parse_arguments(arguments(&["diagram", "Inns", "Players"]))
                .map(|_| ())
                .map_err(|error| error.to_string()),
            Err("more than one state-machine name was provided".to_owned())
        );
        assert_eq!(
            parse_arguments(arguments(&["diagram", "Inns", "--target", "svg"]))
                .map(|_| ())
                .map_err(|error| error.to_string()),
            Err(
                "unknown diagram target `svg`; expected `text`, `mermaid`, or `graphviz`"
                    .to_owned()
            )
        );
    }

    #[test]
    fn help_is_explicit() -> Result<(), CliError> {
        assert!(matches!(
            parse_arguments(arguments(&["--help"]))?,
            Command::Help
        ));
        assert!(matches!(
            parse_arguments(arguments(&["diagram", "--help"]))?,
            Command::Help
        ));
        Ok(())
    }
}