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>]
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(())
}
}