use crate::cloud::CloudOptions; use crate::config::{HazeConfig, Preset}; use crate::service::{Service, ServiceTrait}; use miette::{IntoDiagnostic, Report, Result}; use std::fmt::{Debug, Display}; use std::rc::Rc; use std::str::FromStr; use strum::{ Display, EnumIter, EnumMessage, EnumProperty, EnumString, IntoEnumIterator, IntoStaticStr, }; pub trait SubCommand: Display + EnumMessage + EnumProperty + Debug { fn parent(&self) -> &'static str { "" } fn allows_filter(&self) -> bool { false } fn sub_commands(&self) -> Option>>> { None } } #[derive(Debug, Clone)] pub enum HazeArgs { List { filter: Option, }, Start { options: CloudOptions, }, Stop { filter: Option, }, Test { options: CloudOptions, args: Vec, }, Exec { filter: Option, service: Option, command: Vec, root: bool, }, Occ { filter: Option, command: Vec, }, Db { filter: Option, root: bool, command: Vec, index: Option, }, Clean, Logs { filter: Option, follow: bool, service: Option, count: Option, }, Open { filter: Option, }, Fmt { path: String, }, Integration { options: CloudOptions, args: Vec, }, Shell { options: CloudOptions, command: Vec, }, Pin { filter: Option, }, Unpin { filter: Option, }, Proxy, Git { operation: GitOperation, }, Env { filter: Option, command: String, args: Vec, }, Update, Help { command: Option>, }, Version, Edit { filter: Option, path: String, }, Reload { filter: Option, }, } #[derive( Debug, Clone, Eq, PartialEq, Display, EnumProperty, EnumString, IntoStaticStr, EnumMessage, EnumIter, )] #[strum(serialize_all = "lowercase")] pub enum GitOperation { /// Pull the active branch in all apps Pull, /// Checkout a branch in all apps /// /// "main" and "master" can be used interchangeably. #[strum(props( Args = "[branch] branch to checkout, defaults to the branch matching the current server version [-v] verbose" ))] Checkout { branch: Option, verbose: bool, }, } impl SubCommand for GitOperation { fn parent(&self) -> &'static str { " git" } } #[derive(Debug, Clone, Eq, PartialEq)] pub enum LogService { Service(Service), Database, } impl LogService { pub fn from_type(presets: &[Preset], ty: &str) -> Option { if ty == "db" { return Some(LogService::Database); } Some(LogService::Service( Service::from_type(presets, ty)?.into_iter().next()?, )) } pub fn container_name(&self, cloud_id: &str) -> Option { match self { LogService::Database => Some(format!("{}-db", cloud_id)), LogService::Service(service) => service.container_name(cloud_id), } } } #[derive(Debug, Clone, Eq, PartialEq)] pub enum ExecService { Db, } impl HazeArgs { pub fn parse(config: &HazeConfig, mut args: I) -> Result where S: AsRef + Into + Display, I: Iterator, { let _bin = args.next(); let command_or_filter = match args.next() { Some(s) => s, None => return Ok(HazeArgs::List { filter: None }), }; let (cmd, filter) = match HazeCommand::from_str(command_or_filter.as_ref()) { Ok(cmd) => (cmd, None), Err(_) => { let cmd = match args.next() { Some(cmd) => HazeCommand::from_str(cmd.as_ref()).into_diagnostic()?, None => { return Ok(HazeArgs::List { filter: Some(command_or_filter.into()), }); } }; if !cmd.allows_filter() { return Err(Report::msg(format!( "{} doesn't allow specifying a filter", cmd ))); } (cmd, Some(command_or_filter.into())) } }; match cmd { HazeCommand::List => Ok(HazeArgs::List { filter: filter.or_else(|| args.next().map(S::into)), }), HazeCommand::Start => { let mut args = args.peekable(); let options = CloudOptions::parse(config, &mut args)?; if let Some(leftover) = args.next() { return Err(Report::msg(format!("unrecognized option {}", leftover))); } Ok(HazeArgs::Start { options }) } HazeCommand::Stop => Ok(HazeArgs::Stop { filter }), HazeCommand::Test => { let mut args = args.peekable(); let options = CloudOptions::parse(config, &mut args)?; let args = args.map(S::into).collect(); Ok(HazeArgs::Test { options, args }) } HazeCommand::Integration => { let mut args = args.peekable(); let options = CloudOptions::parse(config, &mut args)?; let args = args.map(S::into).collect(); Ok(HazeArgs::Integration { options, args }) } HazeCommand::Exec => { let mut args = args.peekable(); let service = match args.peek() { Some(arg) if arg.as_ref() == "db" => { args.next(); Some(ExecService::Db) } _ => None, }; let root = match args.peek() { Some(arg) if arg.as_ref() == "root" => { args.next(); true } _ => false, }; let command = args.map(S::into).collect(); Ok(HazeArgs::Exec { filter, service, command, root, }) } HazeCommand::Occ => Ok(HazeArgs::Occ { filter, command: args.map(S::into).collect(), }), HazeCommand::Db => { let mut args = args.peekable(); let root = if let Some(first) = args.peek() { let root = first.as_ref() == "root"; if root { let _ = args.next(); } root } else { false }; let index = if let Some(next) = args.peek() { let all = next.as_ref() == "all"; let is_number = next.as_ref().chars().all(char::is_numeric); if all || is_number { args.next().map(Into::into) } else { None } } else { None }; let command = args.map(S::into).collect(); Ok(HazeArgs::Db { filter, root, command, index, }) } HazeCommand::Clean => Ok(HazeArgs::Clean), HazeCommand::Logs => { let mut args = args.peekable(); let follow = args.next_if(|arg| arg.as_ref() == "-f").is_some(); let service = args .next_if(|arg| LogService::from_type(&config.preset, arg.as_ref()).is_some()) .and_then(|arg| LogService::from_type(&config.preset, arg.as_ref())); Ok(HazeArgs::Logs { filter, follow, service, count: args .next() .map(|arg| arg.as_ref().parse()) .transpose() .into_diagnostic()?, }) } HazeCommand::Open => Ok(HazeArgs::Open { filter }), HazeCommand::Fmt => { let path = args .next() .map(S::into) .ok_or_else(|| Report::msg("No path provided"))?; Ok(HazeArgs::Fmt { path }) } HazeCommand::Shell => { let mut args = args.peekable(); let options = CloudOptions::parse(config, &mut args)?; let command = args.map(S::into).collect(); Ok(HazeArgs::Shell { options, command }) } HazeCommand::Pin => Ok(HazeArgs::Pin { filter }), HazeCommand::Unpin => Ok(HazeArgs::Unpin { filter }), HazeCommand::Proxy => Ok(HazeArgs::Proxy), HazeCommand::Checkout => { let branch = args.next().map(S::into); Ok(HazeArgs::Git { operation: GitOperation::Checkout { branch, verbose: false, }, }) } HazeCommand::Git => { let mut args = args.peekable(); let operation = args .next() .ok_or_else(|| Report::msg("No git operation provided"))?; match operation.as_ref() { "checkout" => { let verbose = args.next_if(|arg| arg.as_ref() == "-v").is_some(); let branch = args.next().map(S::into); let verbose = verbose | args.next_if(|arg| arg.as_ref() == "-v").is_some(); Ok(HazeArgs::Git { operation: GitOperation::Checkout { branch, verbose }, }) } "pull" => Ok(HazeArgs::Git { operation: GitOperation::Pull, }), operation => Err(Report::msg(format!("Unknown git operation {operation}"))), } } HazeCommand::Env => { let mut args = args.map(S::into); let command = args .next() .ok_or_else(|| Report::msg("No command provided"))?; Ok(HazeArgs::Env { filter, command, args: args.collect(), }) } HazeCommand::Update => Ok(HazeArgs::Update), HazeCommand::Help => { let command = args .next() .map(|command| match command.as_ref() { "git" => match args.next() { Some(op) => GitOperation::from_str(op.as_ref()) .map(|op| Rc::new(op) as Rc) .map_err(|_| Report::msg("Unknown command")), None => Ok(Rc::new(HazeCommand::Git) as Rc), }, command => HazeCommand::from_str(command) .map(|command| Rc::new(command) as Rc) .map_err(|_| Report::msg("Unknown command")), }) .transpose()?; Ok(HazeArgs::Help { command }) } HazeCommand::Version => Ok(HazeArgs::Version), HazeCommand::Edit => Ok(HazeArgs::Edit { filter, path: args .next() .ok_or_else(|| Report::msg("No path provided"))? .into(), }), HazeCommand::Reload => Ok(HazeArgs::Reload { filter }), } } } #[derive( Debug, Clone, Copy, Eq, PartialEq, Display, IntoStaticStr, EnumIter, EnumString, EnumMessage, EnumProperty, )] #[strum(serialize_all = "lowercase")] pub enum HazeCommand { /// List all instances List, /// Start a new instance #[strum(props(InstanceArgs = true))] Start, /// Stop an instance Stop, /// Run tests in a new instance #[strum(props( InstanceArgs = true, Args = "[phpunit arguments] arguments to pass to phpunit" ))] Test, /// Run a command in an instance #[strum(props( Args = "[service] run command on a service container instead [command] command to run" ))] Exec, /// Run an occ command in an instance #[strum(props(Args = "[occ arguments] arguments to pass to occ"))] Occ, /// Connect to the database of an instance #[strum(props( Args = "[root] connect to the database as root [db index] database instance to use for sharded setup [sql] sql command to run" ))] Db, /// Remove all non-pinned instances Clean, /// View the logs from an instance or service #[strum(props( Args = "[service] service to show logs from [follow] show logs lines as they appear [count] number of lines to show" ))] Logs, /// Open an instance in the browser Open, /// Run code formatting from a new instance #[strum(props(Args = "[path] path to format"))] Fmt, /// Run integration tests in a new instance #[strum(props(InstanceArgs = true, Args = "[args] arguments to pass to behat"))] Integration, /// Start a shell in an empirical instance #[strum(props(InstanceArgs = true, Args = "[command] command to run"))] Shell, /// Pin an instance Pin, /// Unpin an instance Unpin, /// Start the proxy Proxy, /// Perform git operations on all apps Git, /// Deprecated, use `haze git checkout` instead #[strum(props(Args = "[branch] branch to checkout"))] Checkout, /// Run command with notify_push environment variables Env, #[strum(props(Args = "[command] command to run with environment variables"))] /// Update docker images Update, /// Show help text #[strum(serialize = "--help", to_string = "help")] Help, /// Show version number #[strum(serialize = "--version", to_string = "version")] Version, /// Edit a file in the instance with $EDITOR on the host #[strum(props(Args = "[path] file to edit"))] Edit, /// Reload the php configuration in the instance #[strum(props( Details = "note: you can overwrite php.ini settings with haze [filter] edit /config/php.ini" ))] Reload, } impl SubCommand for HazeCommand { fn allows_filter(&self) -> bool { matches!( self, HazeCommand::List | HazeCommand::Stop | HazeCommand::Exec | HazeCommand::Occ | HazeCommand::Db | HazeCommand::Logs | HazeCommand::Open | HazeCommand::Pin | HazeCommand::Unpin | HazeCommand::Env | HazeCommand::Edit | HazeCommand::Reload ) } fn sub_commands(&self) -> Option>>> { match self { HazeCommand::Git => Some(Box::new(GitOperation::iter().map(|op| Box::new(op) as _))), _ => None, } } } #[test] fn test_arg_parse() { let config = HazeConfig { sources_root: Default::default(), app_directories: Default::default(), work_dir: Default::default(), auto_setup: Default::default(), volume: vec![], blackfire: None, proxy: Default::default(), preset: vec![], }; fn assert_eq(a: A, b: B) { assert_eq!(format!("{a:?}"), format!("{b:?}")); } assert_eq( HazeArgs::parse(&config, vec!["haze"].into_iter()).unwrap(), HazeArgs::List { filter: None }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "test"].into_iter()).unwrap(), HazeArgs::Test { options: Default::default(), args: vec![], }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "asdasd"].into_iter()).unwrap(), HazeArgs::List { filter: Some("asdasd".to_string()), }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "asdasd", "db"].into_iter()).unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: false, command: Vec::new(), index: None, }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "asdasd", "db", "root"].into_iter()).unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: true, command: Vec::new(), index: None, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "db", "select", "1"].into_iter(), ) .unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: false, command: vec!["select".to_string(), "1".to_string()], index: None, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "db", "root", "select 1"].into_iter(), ) .unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: true, command: vec!["select 1".to_string()], index: None, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "db", "root", "1", "select 1"].into_iter(), ) .unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: true, command: vec!["select 1".to_string()], index: Some("1".into()), }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "db", "all", "select 1"].into_iter(), ) .unwrap(), HazeArgs::Db { filter: Some("asdasd".to_string()), root: false, command: vec!["select 1".to_string()], index: Some("all".into()), }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "exec", "foo", "bar"].into_iter()).unwrap(), HazeArgs::Exec { filter: None, service: None, command: vec!["foo".to_string(), "bar".to_string()], root: false, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "exec", "root", "foo", "bar"].into_iter(), ) .unwrap(), HazeArgs::Exec { filter: None, service: None, command: vec!["foo".to_string(), "bar".to_string()], root: true, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "exec", "foo", "bar"].into_iter(), ) .unwrap(), HazeArgs::Exec { filter: Some("asdasd".to_string()), service: None, command: vec!["foo".to_string(), "bar".to_string()], root: false, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "exec", "db", "foo", "bar"].into_iter(), ) .unwrap(), HazeArgs::Exec { filter: Some("asdasd".to_string()), service: Some(ExecService::Db), command: vec!["foo".to_string(), "bar".to_string()], root: false, }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "test", "foo", "bar"].into_iter()).unwrap(), HazeArgs::Test { options: Default::default(), args: vec!["foo".into(), "bar".into()], }, ); assert_eq( HazeArgs::parse(&config, vec!["haze", "logs", "-f", "smb"].into_iter()).unwrap(), HazeArgs::Logs { filter: None, follow: true, service: Some(LogService::from_type(&[], "smb").unwrap()), count: None, }, ); assert_eq( HazeArgs::parse( &config, vec!["haze", "asdasd", "logs", "smb", "123"].into_iter(), ) .unwrap(), HazeArgs::Logs { filter: Some("asdasd".to_string()), follow: false, service: Some(LogService::from_type(&[], "smb").unwrap()), count: Some(123), }, ); }