commit acfc75b4a2e55a0cb7ce9ac5e6710afc501610aa
parent 579e7060290f99c6515a884d8dca0b808dd8c01a
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 01:32:05 -0700
cli: add tangle command model
Diffstat:
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"
);
}