pub mod consts;
pub mod error;
pub mod profile;
pub mod task;
use crate::consts::*;
use crate::error::CtGenError;
use crate::profile::{CtGenProfile, CtGenProfileConfigOverrides};
use crate::task::CtGenTask;
use anyhow::Result;
use indexmap::IndexMap;
use regex::Regex;
use std::env;
use std::path::MAIN_SEPARATOR;
use tokio::io::AsyncWriteExt;
#[derive(Clone, Default, Debug)]
pub struct CtGen {
config_file: String,
profiles: IndexMap<String, String>,
current_profile: Option<CtGenProfile>,
}
impl CtGen {
pub async fn new() -> Result<Self> {
let config_path = CtGen::get_config_dir()?;
CtGen::init_config_dir(&config_path).await?;
let config_file = CtGen::get_config_file(&config_path);
if !CtGen::file_is_writable(&config_file).await {
return Err(CtGenError::InitError(format!("Config file not accessible: {}", &config_file)).into());
}
if !CtGen::file_exists(&config_file).await {
CtGen::init_config_file(&config_file).await?;
}
let profiles = CtGen::load_profiles(&config_file).await?;
Ok(Self {
config_file,
profiles,
..Default::default()
})
}
pub fn get_config_dir() -> Result<String> {
let path = dirs::config_dir().ok_or(CtGenError::InitError("Failed to get config directory.".to_string()))?;
Ok(format!(
"{}{}{}",
path.into_os_string()
.into_string()
.map_err(|e| CtGenError::InitError(format!("Failed to parse UTF-8 path: {:?}", e)))?,
MAIN_SEPARATOR,
CONFIG_DIR_NAME
))
}
pub fn get_current_working_dir() -> Result<String> {
Ok(env::current_dir()
.map_err(|e| CtGenError::RuntimeError(format!("Failed to get current working directory: {}", e)))?
.into_os_string()
.into_string()
.map_err(|s| CtGenError::RuntimeError(format!("Failed to parse UTC-8 path: {:?}", s)))?)
}
pub fn get_filepath(path: &str, file: &str) -> String {
format!("{}{}{}", path, MAIN_SEPARATOR, file)
}
pub async fn get_realpath(path: &str) -> Result<String> {
let mut path = path.to_string();
if path.starts_with('~') {
if let Ok(home) = env::var("HOME") {
path.remove(0);
path.insert(0, MAIN_SEPARATOR);
path.insert_str(0, &home);
}
}
Ok(tokio::fs::canonicalize(path)
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to resolve path: {:?}", e)))?
.into_os_string()
.into_string()
.map_err(|s| CtGenError::RuntimeError(format!("Failed to parse UTC-8 path: {:?}", s)))?)
}
pub async fn get_real_filepath(path: &str, file: &str) -> Result<String> {
CtGen::get_realpath(&CtGen::get_filepath(path, file)).await
}
pub fn get_config_file(path: &str) -> String {
CtGen::get_filepath(path, CONFIG_FILE_NAME)
}
pub async fn file_is_writable(file: &str) -> bool {
tokio::fs::try_exists(file).await.is_ok()
}
pub async fn file_exists(file: &str) -> bool {
if let Ok(r) = tokio::fs::try_exists(file).await {
return r;
}
false
}
async fn init_config_file(config_file: &str) -> Result<()> {
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.create_new(true)
.open(&config_file)
.await
.map_err(|e| CtGenError::InitError(format!("Cannot create config file: {}", e)))?;
file.write("[profiles]".as_bytes())
.await
.map_err(|_e| CtGenError::InitError(format!("Cannot write to config file: {}", config_file)))?;
file.flush()
.await
.map_err(|_e| CtGenError::InitError(format!("Cannot flush to config file: {}", config_file)))?;
Ok(())
}
pub async fn init_config_dir(config_path: &str) -> Result<()> {
Ok(tokio::fs::create_dir_all(&config_path)
.await
.map_err(|e| CtGenError::InitError(format!("Cannot create config directory: {}", e)))?)
}
pub fn get_name_regex() -> Result<Regex> {
Ok(Regex::new(CONFIG_NAME_PATTERN).map_err(|e| CtGenError::ValidationError(format!("Failed to compile regex pattern: {}", e)))?)
}
async fn load_profiles(config_file: &str) -> Result<IndexMap<String, String>> {
match tokio::fs::read_to_string(config_file).await {
Ok(c) => {
let mut profiles: IndexMap<String, String> = IndexMap::new();
let config = c
.parse::<toml::Table>()
.map_err(|e| CtGenError::InitError(format!("Failed to parse profiles: {}", e)))?;
if let Some(config_profiles) = config.get("profiles") {
if config_profiles.is_table() {
for (profile_name, profile_file) in config_profiles
.as_table()
.ok_or_else(|| CtGenError::ValidationError(format!("Invalid profiles table.")))?
.iter()
{
profiles.insert(
profile_name.to_string(),
profile_file
.as_str()
.ok_or_else(|| {
CtGenError::ValidationError(format!("Invalid profile file for profile `{}`.", profile_name))
})?
.to_string(),
);
}
}
}
Ok(profiles)
}
Err(e) => Err(CtGenError::InitError(format!("Failed to load profiles: {}", e)).into()),
}
}
async fn save_profiles(&self) -> Result<()> {
let mut profiles_config = toml::map::Map::new();
let mut profiles = toml::Table::new();
for (profile_name, profile_file) in self.profiles.iter() {
profiles.insert(profile_name.to_string(), toml::Value::String(profile_file.to_string()));
}
profiles_config.insert("profiles".to_string(), toml::Value::Table(profiles));
let toml = toml::to_string_pretty(&profiles_config)
.map_err(|e| CtGenError::RuntimeError(format!("Failed to generate toml file: {}", e)))?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&self.config_file)
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to open toml file: {}", e)))?;
file.write_all(toml.as_bytes())
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to write toml file: {}", e)))?;
file.flush()
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to flush toml file: {}", e)))?;
Ok(())
}
pub fn get_profiles(&self) -> &IndexMap<String, String> {
&self.profiles
}
pub async fn add_profile(&mut self, name: &str, path: &str) -> Result<CtGenProfile> {
let regex = CtGen::get_name_regex()?;
if !name.is_empty() && !regex.is_match(name) {
return Err(CtGenError::ValidationError(format!(
"Invalid profile name: {}. Make sure it matches {}",
name, CONFIG_NAME_PATTERN
))
.into());
}
let fullpath = if path == "." || path == "./" {
let cwd = CtGen::get_current_working_dir()?;
CtGen::get_real_filepath(&cwd, PROFILE_DEFAULT_FILENAME).await?
} else if !path.ends_with(".toml") {
CtGen::get_real_filepath(path, PROFILE_DEFAULT_FILENAME).await?
} else {
CtGen::get_realpath(path).await?
};
if !CtGen::file_exists(&fullpath).await {
return Err(CtGenError::ValidationError(format!("Profile config file not found: {}", fullpath)).into());
}
let profile = CtGenProfile::load(&fullpath, name).await?;
profile.validate().await?;
let name = if name.is_empty() { profile.configuration().name() } else { name };
self.profiles.insert(name.to_string(), fullpath.clone());
self.save_profiles().await?;
Ok(profile)
}
pub async fn remove_profile(&mut self, name: &str) -> Result<()> {
if self.profiles.contains_key(name) {
self.profiles.swap_remove(name);
}
if let Some(profile) = self.current_profile.clone() {
if profile.name() == name {
self.current_profile = None;
}
}
self.save_profiles().await
}
pub async fn set_current_profile(&mut self, name: &str) -> Result<&CtGenProfile> {
if let Some(profile_path) = self.profiles.get(name) {
let profile = CtGenProfile::load(profile_path, name).await?;
profile.validate().await?;
self.current_profile = Some(profile);
self.current_profile
.as_ref()
.ok_or(CtGenError::ValidationError("Invalid profile. No such profile found".to_string()).into())
} else {
Err(CtGenError::ValidationError("Invalid profile name. No such profile found".to_string()).into())
}
}
pub fn get_current_profile(&self) -> Option<&CtGenProfile> {
self.current_profile.as_ref()
}
pub async fn init_profile(&mut self, path: &str, name: &str) -> Result<CtGenProfile> {
let regex = CtGen::get_name_regex()?;
let fullpath = if path == "." || path == "./" {
CtGen::get_current_working_dir()?
} else if regex.is_match(path) {
CtGen::get_filepath(&CtGen::get_current_working_dir()?, path)
} else {
CtGen::get_realpath(path).await?
};
let profile = CtGenProfile::new(&fullpath, name);
CtGen::init_config_dir(&fullpath).await?;
CtGen::init_config_dir(profile.templates_dir().as_str()).await?;
CtGen::init_config_dir(profile.scripts_dir().as_str()).await?;
let toml = toml::to_string(&profile).map_err(|e| CtGenError::RuntimeError(format!("Failed to generate toml file: {}", e)))?;
let toml = toml.replace(
"\n[prompt.dummy.options]\n1 = \"Yes\"\n0 = \"No\"",
r#"options = { 1 = "Yes", 0 = "No" }"#,
);
let config_file = CtGen::get_filepath(&fullpath, PROFILE_DEFAULT_FILENAME);
let mut file = tokio::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&config_file)
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to open toml file: {}", e)))?;
file.write_all(toml.as_bytes())
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to write toml file: {}", e)))?;
file.flush()
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to flush toml file: {}", e)))?;
for target in profile.targets() {
let target = profile
.target(target)
.ok_or(CtGenError::ValidationError(format!("Target `{}` does not exist.", target)))?;
let template_file = CtGen::get_filepath(profile.templates_dir().as_str(), format!("{}.hbs", target.template()).as_str());
let template = DUMMY_TEMPLATE;
let mut file = tokio::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(template_file)
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to open template file: {}", e)))?;
file.write_all(template.as_bytes())
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to write template file: {}", e)))?;
file.flush()
.await
.map_err(|e| CtGenError::RuntimeError(format!("Failed to flush template file: {}", e)))?;
}
self.add_profile(name, &config_file).await
}
pub async fn create_task(
&self,
context_dir: &str,
table: Option<&str>,
profile_overrides: Option<CtGenProfileConfigOverrides>,
) -> Result<CtGenTask> {
let real_context_path = CtGen::get_realpath(context_dir).await?;
if let Some(profile) = self.current_profile.as_ref() {
return CtGenTask::new(profile, &real_context_path, table, profile_overrides).await;
}
Err(CtGenError::RuntimeError("No current profile".to_string()).into())
}
}