tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit acfc75b4a2e55a0cb7ce9ac5e6710afc501610aa
parent 579e7060290f99c6515a884d8dca0b808dd8c01a
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 01:32:05 -0700

cli: add tangle command model

Diffstat:
Mcrates/tangle/src/lib.rs | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle/src/main.rs | 22++++++++++++++++------
Mcrates/tangle/tests/version.rs | 24+++++++++++++++++++++---
3 files changed, 217 insertions(+), 10 deletions(-)

diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs @@ -1,15 +1,136 @@ #![forbid(unsafe_code)] +use std::fmt; + pub const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const USAGE: &str = "\ +usage: tangle [--version] <command> + +commands: + migrate + run + event import + event export + projection rebuild"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TangleCommand { + Version, + Help, + Migrate, + Run, + EventImport, + EventExport, + ProjectionRebuild, +} + +impl TangleCommand { + pub fn as_str(self) -> &'static str { + match self { + Self::Version => "version", + Self::Help => "help", + Self::Migrate => "migrate", + Self::Run => "run", + Self::EventImport => "event import", + Self::EventExport => "event export", + Self::ProjectionRebuild => "projection rebuild", + } + } + + pub fn implemented(self) -> bool { + matches!(self, Self::Version | Self::Help) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TangleCliError { + UnknownCommand(String), + MissingNestedCommand(&'static str), + UnexpectedArgument { command: String, argument: String }, +} + +impl fmt::Display for TangleCliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownCommand(command) => write!(formatter, "unknown command: {command}"), + Self::MissingNestedCommand(command) => { + write!(formatter, "{command} command requires a nested command") + } + Self::UnexpectedArgument { command, argument } => { + write!( + formatter, + "{command} command does not accept argument: {argument}" + ) + } + } + } +} + +impl std::error::Error for TangleCliError {} pub fn version_output() -> String { format!("{PACKAGE_NAME} {PACKAGE_VERSION}") } +pub fn usage_output() -> &'static str { + USAGE +} + +pub fn parse_tangle_command<I, S>(args: I) -> Result<TangleCommand, TangleCliError> +where + I: IntoIterator<Item = S>, + S: Into<String>, +{ + let mut args = args.into_iter().map(Into::into); + let Some(first) = args.next() else { + return Ok(TangleCommand::Help); + }; + let command = match first.as_str() { + "--version" | "-V" => TangleCommand::Version, + "--help" | "-h" | "help" => TangleCommand::Help, + "migrate" => TangleCommand::Migrate, + "run" => TangleCommand::Run, + "event" => { + let Some(nested) = args.next() else { + return Err(TangleCliError::MissingNestedCommand("event")); + }; + match nested.as_str() { + "import" => TangleCommand::EventImport, + "export" => TangleCommand::EventExport, + _ => return Err(TangleCliError::UnknownCommand(format!("event {nested}"))), + } + } + "projection" => { + let Some(nested) = args.next() else { + return Err(TangleCliError::MissingNestedCommand("projection")); + }; + match nested.as_str() { + "rebuild" => TangleCommand::ProjectionRebuild, + _ => { + return Err(TangleCliError::UnknownCommand(format!( + "projection {nested}" + ))); + } + } + } + _ => return Err(TangleCliError::UnknownCommand(first)), + }; + if let Some(argument) = args.next() { + return Err(TangleCliError::UnexpectedArgument { + command: command.as_str().to_owned(), + argument, + }); + } + Ok(command) +} + #[cfg(test)] mod tests { - use super::{PACKAGE_NAME, PACKAGE_VERSION, version_output}; + use super::{ + PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, parse_tangle_command, + usage_output, version_output, + }; #[test] fn package_name_is_tangle() { @@ -25,4 +146,62 @@ mod tests { fn version_output_contains_package_and_version() { assert_eq!(version_output(), "tangle 0.1.0"); } + + #[test] + fn usage_output_lists_supported_command_model() { + assert_eq!( + usage_output(), + "usage: tangle [--version] <command>\n\ncommands:\n migrate\n run\n event import\n event export\n projection rebuild" + ); + } + + #[test] + fn command_model_parses_known_commands() { + let cases = [ + (Vec::<&str>::new(), TangleCommand::Help), + (vec!["--version"], TangleCommand::Version), + (vec!["-V"], TangleCommand::Version), + (vec!["--help"], TangleCommand::Help), + (vec!["help"], TangleCommand::Help), + (vec!["migrate"], TangleCommand::Migrate), + (vec!["run"], TangleCommand::Run), + (vec!["event", "import"], TangleCommand::EventImport), + (vec!["event", "export"], TangleCommand::EventExport), + ( + vec!["projection", "rebuild"], + TangleCommand::ProjectionRebuild, + ), + ]; + + for (args, expected) in cases { + assert_eq!(parse_tangle_command(args).expect("command"), expected); + assert_eq!( + expected.implemented(), + matches!(expected, TangleCommand::Version | TangleCommand::Help) + ); + } + } + + #[test] + fn command_model_rejects_unknown_or_extra_arguments() { + assert_eq!( + parse_tangle_command(["unknown"]).expect_err("unknown"), + TangleCliError::UnknownCommand("unknown".to_owned()) + ); + assert_eq!( + parse_tangle_command(["event"]).expect_err("nested"), + TangleCliError::MissingNestedCommand("event") + ); + assert_eq!( + parse_tangle_command(["projection", "bad"]).expect_err("projection"), + TangleCliError::UnknownCommand("projection bad".to_owned()) + ); + assert_eq!( + parse_tangle_command(["run", "--extra"]).expect_err("extra"), + TangleCliError::UnexpectedArgument { + command: "run".to_owned(), + argument: "--extra".to_owned() + } + ); + } } diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs @@ -4,16 +4,26 @@ use std::env; use std::process::ExitCode; fn main() -> ExitCode { - let mut args = env::args().skip(1); - match args.next().as_deref() { - Some("--version") | Some("-V") => { + let command = match tangle::parse_tangle_command(env::args().skip(1)) { + Ok(command) => command, + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", tangle::usage_output()); + return ExitCode::from(2); + } + }; + match command { + tangle::TangleCommand::Version => { println!("{}", tangle::version_output()); ExitCode::SUCCESS } - Some(_) => { - eprintln!("usage: tangle [--version]"); + tangle::TangleCommand::Help => { + println!("{}", tangle::usage_output()); + ExitCode::SUCCESS + } + command => { + eprintln!("command not implemented: {}", command.as_str()); ExitCode::from(2) } - None => ExitCode::SUCCESS, } } diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -15,13 +15,16 @@ fn tangle_version_command_reports_package_version() { } #[test] -fn tangle_without_args_exits_successfully() { +fn tangle_without_args_reports_usage() { let output = Command::new(env!("CARGO_BIN_EXE_tangle")) .output() .expect("run tangle without args"); assert!(output.status.success()); - assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "usage: tangle [--version] <command>\n\ncommands:\n migrate\n run\n event import\n event export\n projection rebuild\n" + ); assert!(output.stderr.is_empty()); } @@ -36,6 +39,21 @@ fn tangle_unknown_arg_reports_usage_error() { assert!(output.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&output.stderr), - "usage: tangle [--version]\n" + "unknown command: --unknown\nusage: tangle [--version] <command>\n\ncommands:\n migrate\n run\n event import\n event export\n projection rebuild\n" + ); +} + +#[test] +fn tangle_known_future_commands_report_not_implemented() { + let output = Command::new(env!("CARGO_BIN_EXE_tangle")) + .args(["event", "import"]) + .output() + .expect("run tangle event import"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "command not implemented: event import\n" ); }