use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use shuttle_common::config::{Config, ConfigManager, GlobalConfig, GlobalConfigManager};
use shuttle_common::constants::SHUTTLE_API_URL;
use tracing::trace;
use crate::args::ProjectArgs;
use crate::init::create_or_update_ignore_file;
pub struct LocalConfigManager {
directory: PathBuf,
file_name: String,
}
impl LocalConfigManager {
pub fn new<P: AsRef<Path>>(directory: P, file_name: String) -> Self {
Self {
directory: directory.as_ref().to_path_buf(),
file_name,
}
}
}
impl ConfigManager for LocalConfigManager {
fn directory(&self) -> PathBuf {
self.directory.clone()
}
fn filename(&self) -> PathBuf {
PathBuf::from(&self.file_name)
}
}
#[derive(Deserialize, Serialize, Default)]
pub struct ProjectConfig {
pub name: Option<String>,
pub assets: Option<Vec<String>>,
pub deploy: Option<ProjectDeployConfig>,
pub build: Option<ProjectBuildConfig>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct ProjectDeployConfig {
pub include: Option<Vec<String>>,
pub deny_dirty: Option<bool>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct ProjectBuildConfig {
pub assets: Option<Vec<String>>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct InternalProjectConfig {
pub id: Option<String>,
}
pub struct RequestContext {
global: Config<GlobalConfigManager, GlobalConfig>,
project: Option<Config<LocalConfigManager, ProjectConfig>>,
project_internal: Option<Config<LocalConfigManager, InternalProjectConfig>>,
api_url: Option<String>,
}
impl RequestContext {
pub fn load_global(env_override: Option<String>) -> Result<Self> {
let mut global = Config::new(GlobalConfigManager::new(env_override)?);
if !global.exists() {
global.create()?;
}
global
.open()
.context("Unable to load global configuration")?;
Ok(Self {
global,
project: None,
project_internal: None,
api_url: None,
})
}
pub fn load_local_internal_config(&mut self, project_args: &ProjectArgs) -> Result<()> {
let workspace_path = project_args
.workspace_path()
.unwrap_or(project_args.working_directory.clone());
trace!(
"looking for .shuttle/config.toml in {}",
workspace_path.display()
);
let local_manager =
LocalConfigManager::new(workspace_path, ".shuttle/config.toml".to_string());
let mut project_internal = Config::new(local_manager);
if !project_internal.exists() {
trace!("no local .shuttle/config.toml found");
project_internal.replace(InternalProjectConfig::default());
} else {
trace!("found a local .shuttle/config.toml");
project_internal.open()?;
}
let config = project_internal.as_mut().unwrap();
match (&project_args.id, &config.id) {
(Some(id_from_args), _) => {
trace!("using command-line project id");
let id_to_use = if let Some(proj_id_uppercase) =
id_from_args.strip_prefix("proj_").and_then(|suffix| {
(suffix.len() == 26)
.then_some(format!("proj_{}", suffix.to_ascii_uppercase()))
}) {
if *id_from_args != proj_id_uppercase {
eprintln!("INFO: Converted project id to '{}'", proj_id_uppercase);
proj_id_uppercase
} else {
id_from_args.clone()
}
} else {
tracing::warn!("project id with bad format detected: '{id_from_args}'");
id_from_args.clone()
};
config.id = Some(id_to_use);
}
(None, Some(_)) => {
trace!("using .shuttle/config.toml project id");
}
(None, None) => {
trace!("no project id in args or config found");
}
};
self.project_internal = Some(project_internal);
Ok(())
}
pub fn set_project_id(&mut self, id: String) {
*self.project_internal.as_mut().unwrap().as_mut().unwrap() =
InternalProjectConfig { id: Some(id) };
}
pub fn save_local_internal(&mut self) -> Result<()> {
self.project_internal.as_ref().unwrap().save()?;
create_or_update_ignore_file(
&self
.project
.as_ref()
.unwrap()
.manager
.directory
.join(".gitignore"),
)
.context("Failed to create .gitignore file")?;
Ok(())
}
pub fn load_local_config(&mut self, project_args: &ProjectArgs) -> Result<()> {
self.project = Some(Self::get_local_config(project_args)?);
Ok(())
}
fn get_local_config(
project_args: &ProjectArgs,
) -> Result<Config<LocalConfigManager, ProjectConfig>> {
let workspace_path = project_args
.workspace_path()
.unwrap_or(project_args.working_directory.clone());
trace!("looking for Shuttle.toml in {}", workspace_path.display());
if !workspace_path.join("Shuttle.toml").exists()
&& workspace_path.join("shuttle.toml").exists()
{
eprintln!("WARN: Lowercase 'shuttle.toml' detected, please use 'Shuttle.toml'")
}
let local_manager = LocalConfigManager::new(workspace_path, "Shuttle.toml".to_string());
let mut project = Config::new(local_manager);
if !project.exists() {
trace!("no local Shuttle.toml found");
project.replace(ProjectConfig::default());
} else {
trace!("found a local Shuttle.toml");
project.open()?;
}
let config = project.as_mut().unwrap();
match (&project_args.name, &config.name) {
(Some(name_from_args), _) => {
trace!("using command-line project name");
config.name = Some(name_from_args.clone());
}
(None, Some(_)) => {
trace!("using Shuttle.toml project name");
}
(None, None) => {
trace!("using crate name as project name");
config.name = Some(project_args.project_name()?);
}
};
Ok(project)
}
pub fn set_api_url(&mut self, api_url: Option<String>) {
self.api_url = api_url;
}
pub fn api_url(&self) -> String {
if let Some(api_url) = self.api_url.clone() {
api_url
} else if let Some(api_url) = self.global.as_ref().unwrap().api_url.clone() {
api_url
} else {
SHUTTLE_API_URL.to_string()
}
}
pub fn api_key(&self) -> Result<String> {
match std::env::var("SHUTTLE_API_KEY") {
Ok(key) => Ok(key),
Err(_) => match self.global.as_ref().unwrap().api_key.clone() {
Some(key) => Ok(key),
None => Err(anyhow!(
"Configuration file: `{}`",
self.global.manager.path().display()
)
.context("No valid API key found, try logging in with `shuttle login`")),
},
}
}
pub fn project_directory(&self) -> &Path {
self.project.as_ref().unwrap().manager.directory.as_path()
}
pub fn set_api_key(&mut self, api_key: String) -> Result<()> {
self.global.as_mut().unwrap().api_key = Some(api_key);
self.global.save()
}
pub fn clear_api_key(&mut self) -> Result<()> {
self.global.as_mut().unwrap().api_key = None;
self.global.save()
}
pub fn project_name(&self) -> &str {
self.project
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.name
.as_ref()
.unwrap()
.as_str()
}
pub fn include(&self) -> Option<&Vec<String>> {
self.project
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.deploy
.as_ref()
.and_then(|d| d.include.as_ref())
.or(self
.project
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.assets
.as_ref())
}
pub fn deny_dirty(&self) -> Option<bool> {
self.project
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.deploy
.as_ref()
.and_then(|d| d.deny_dirty)
}
pub fn project_id_found(&self) -> bool {
self.project_internal
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.id
.is_some()
}
pub fn project_id(&self) -> &str {
self.project_internal
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.id
.as_ref()
.unwrap()
.as_str()
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{args::ProjectArgs, config::RequestContext};
use super::{Config, LocalConfigManager, ProjectConfig};
fn path_from_workspace_root(path: &str) -> PathBuf {
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("..")
.join(path)
}
fn unwrap_project_name(config: &Config<LocalConfigManager, ProjectConfig>) -> String {
config.as_ref().unwrap().name.as_ref().unwrap().to_string()
}
#[test]
fn get_local_config_finds_name_in_cargo_toml() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/"),
name: None,
id: None,
};
let local_config = RequestContext::get_local_config(&project_args).unwrap();
assert_eq!(unwrap_project_name(&local_config), "hello-world");
}
#[test]
fn get_local_config_finds_name_from_workspace_dir() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/rocket/workspace/hello-world/"),
name: None,
id: None,
};
let local_config = RequestContext::get_local_config(&project_args).unwrap();
assert_eq!(unwrap_project_name(&local_config), "workspace");
}
#[test]
fn setting_name_overrides_name_in_config() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/"),
name: Some("my-fancy-project-name".to_owned()),
id: None,
};
let local_config = RequestContext::get_local_config(&project_args).unwrap();
assert_eq!(unwrap_project_name(&local_config), "my-fancy-project-name");
}
}