use std::{
ffi::OsString,
fs::create_dir_all,
io::{self, ErrorKind},
path::PathBuf,
};
use anyhow::{bail, Context};
use cargo_metadata::MetadataCommand;
use clap::{
builder::{OsStringValueParser, PossibleValue, TypedValueParser},
Parser, ValueEnum,
};
use clap_complete::Shell;
use shuttle_common::{models::project::DEFAULT_IDLE_MINUTES, project::ProjectName};
use uuid::Uuid;
#[derive(Parser)]
#[command(
version,
arg(clap::Arg::new("dummy")
.value_parser([PossibleValue::new("shuttle")])
.required(false)
.hide(true))
)]
pub struct ShuttleArgs {
#[command(flatten)]
pub project_args: ProjectArgs,
#[arg(long, env = "SHUTTLE_API")]
pub api_url: Option<String>,
#[command(subcommand)]
pub cmd: Command,
}
#[derive(Parser, Debug)]
pub struct ProjectArgs {
#[arg(global = true, long, alias = "wd", default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_init_path))]
pub working_directory: PathBuf,
#[arg(global = true, long)]
pub name: Option<ProjectName>,
}
impl ProjectArgs {
pub fn workspace_path(&self) -> anyhow::Result<PathBuf> {
let path = MetadataCommand::new()
.current_dir(&self.working_directory)
.exec()
.context("failed to get cargo metadata")?
.workspace_root
.into();
Ok(path)
}
pub fn project_name(&self) -> anyhow::Result<ProjectName> {
let workspace_path = self.workspace_path()?;
let meta = MetadataCommand::new()
.current_dir(&workspace_path)
.exec()
.unwrap();
let package_name = if let Some(root_package) = meta.root_package() {
root_package.name.clone().parse()?
} else {
workspace_path
.file_name()
.context("failed to get project name from workspace path")?
.to_os_string()
.into_string()
.expect("workspace file name should be valid unicode")
.parse()?
};
Ok(package_name)
}
}
#[derive(Parser)]
pub enum Command {
Init(InitArgs),
Run(RunArgs),
Deploy(DeployArgs),
#[command(subcommand)]
Deployment(DeploymentCommand),
Status,
Stop,
Logs {
id: Option<Uuid>,
#[arg(short, long)]
latest: bool,
#[arg(short, long)]
follow: bool,
},
#[command(subcommand)]
Project(ProjectCommand),
#[command(subcommand)]
Resource(ResourceCommand),
Secrets,
Clean,
Login(LoginArgs),
Logout(LogoutArgs),
Generate {
#[arg(short, long, env, default_value_t = Shell::Bash)]
shell: Shell,
#[arg(short, long, env)]
output: Option<PathBuf>,
},
Feedback,
}
#[derive(Parser)]
pub enum DeploymentCommand {
List {
#[arg(long, default_value = "1")]
page: u32,
#[arg(long, default_value = "10")]
limit: u32,
},
Status {
id: Uuid,
},
}
#[derive(Parser)]
pub enum ResourceCommand {
List,
}
#[derive(Parser)]
pub enum ProjectCommand {
Start(ProjectStartArgs),
Status {
#[arg(short, long)]
follow: bool,
},
Stop,
Restart(ProjectStartArgs),
List {
#[arg(long, default_value = "1")]
page: u32,
#[arg(long, default_value = "10")]
limit: u32,
},
}
#[derive(Parser, Debug)]
pub struct ProjectStartArgs {
#[arg(long, default_value_t = DEFAULT_IDLE_MINUTES)]
pub idle_minutes: u64,
}
#[derive(Parser, Clone, Debug)]
pub struct LoginArgs {
#[arg(long)]
pub api_key: Option<String>,
}
#[derive(Parser, Clone, Debug)]
pub struct LogoutArgs {
#[arg(long)]
pub reset_api_key: bool,
}
#[derive(Parser)]
pub struct DeployArgs {
#[arg(long, alias = "ad")]
pub allow_dirty: bool,
#[arg(long, alias = "nt")]
pub no_test: bool,
}
#[derive(Parser, Debug)]
pub struct RunArgs {
#[arg(long, short = 'p', env, default_value = "8000")]
pub port: u16,
#[arg(long)]
pub external: bool,
#[arg(long, short = 'r')]
pub release: bool,
}
#[derive(Parser, Clone, Debug)]
pub struct InitArgs {
#[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])]
pub template: Option<InitTemplateArg>,
#[arg(long)]
pub from: Option<String>,
#[arg(long, requires = "from")]
pub subfolder: Option<String>,
#[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_init_path))]
pub path: PathBuf,
#[arg(long)]
pub create_env: bool,
#[command(flatten)]
pub login_args: LoginArgs,
}
#[derive(ValueEnum, Clone, Debug, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "kebab-case")]
pub enum InitTemplateArg {
ActixWeb,
Axum,
Poem,
Poise,
Rocket,
Salvo,
Serenity,
Thruster,
Tide,
Tower,
Warp,
None,
}
pub const EXAMPLES_REPO: &str = "https://github.com/shuttle-hq/shuttle-examples";
#[derive(Clone, Debug, PartialEq)]
pub struct TemplateLocation {
pub auto_path: String,
pub subfolder: Option<String>,
}
impl InitArgs {
pub fn git_template(&self) -> anyhow::Result<Option<TemplateLocation>> {
if self.from.is_some() && self.template.is_some() {
bail!("Template and From args can not be set at the same time.");
}
Ok(if let Some(from) = self.from.clone() {
Some(TemplateLocation {
auto_path: from,
subfolder: self.subfolder.clone(),
})
} else {
self.template.as_ref().map(|t| t.template())
})
}
}
impl InitTemplateArg {
pub fn template(&self) -> TemplateLocation {
use InitTemplateArg::*;
let path = match self {
ActixWeb => "actix-web/hello-world",
Axum => "axum/hello-world",
Poem => "poem/hello-world",
Poise => "poise/hello-world",
Rocket => "rocket/hello-world",
Salvo => "salvo/hello-world",
Serenity => "serenity/hello-world",
Thruster => "thruster/hello-world",
Tide => "tide/hello-world",
Tower => "tower/hello-world",
Warp => "warp/hello-world",
None => "custom-service/none",
};
TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some(path.to_string()),
}
}
}
fn parse_path(path: OsString) -> Result<PathBuf, String> {
dunce::canonicalize(&path).map_err(|e| format!("could not turn {path:?} into a real path: {e}"))
}
pub(crate) fn parse_init_path(path: OsString) -> Result<PathBuf, io::Error> {
create_dir_all(&path).map_err(|e| {
io::Error::new(
ErrorKind::InvalidInput,
format!("Could not create directory: {e}"),
)
})?;
parse_path(path).map_err(|e| io::Error::new(ErrorKind::InvalidInput, e))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::path_from_workspace_root;
use clap::CommandFactory;
#[test]
fn test_shuttle_args() {
ShuttleArgs::command().debug_assert();
}
#[test]
fn test_init_args_framework() {
let init_args = InitArgs {
template: Some(InitTemplateArg::Tower),
from: None,
subfolder: None,
create_env: false,
login_args: LoginArgs { api_key: None },
path: PathBuf::new(),
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("tower/hello-world".into())
})
);
let init_args = InitArgs {
template: Some(InitTemplateArg::Axum),
from: None,
subfolder: None,
create_env: false,
login_args: LoginArgs { api_key: None },
path: PathBuf::new(),
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("axum/hello-world".into())
})
);
let init_args = InitArgs {
template: Some(InitTemplateArg::None),
from: None,
subfolder: None,
create_env: false,
login_args: LoginArgs { api_key: None },
path: PathBuf::new(),
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("custom-service/none".into())
})
);
let init_args = InitArgs {
template: None,
from: Some("https://github.com/some/repo".into()),
subfolder: Some("some/path".into()),
create_env: false,
login_args: LoginArgs { api_key: None },
path: PathBuf::new(),
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: "https://github.com/some/repo".into(),
subfolder: Some("some/path".into())
})
);
let init_args = InitArgs {
template: None,
from: None,
subfolder: None,
create_env: false,
login_args: LoginArgs { api_key: None },
path: PathBuf::new(),
};
assert_eq!(init_args.git_template().unwrap(), None);
}
#[test]
fn workspace_path() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
};
assert_eq!(
project_args.workspace_path().unwrap(),
path_from_workspace_root("examples/axum/hello-world/")
);
}
#[test]
fn project_name() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
};
assert_eq!(
project_args.project_name().unwrap().to_string(),
"hello-world"
);
}
#[test]
fn project_name_in_workspace() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root(
"examples/rocket/workspace/hello-world/src",
),
name: None,
};
assert_eq!(
project_args.project_name().unwrap().to_string(),
"workspace"
);
}
}