use crate::{git::init_repo, Error, Recipe, Service, Template};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs::{create_dir, File};
use std::io::prelude::*;
use std::path::Path;
use std::sync::{Arc, RwLock};
use tracing::{debug, instrument};
const RESERVED_VALUES: &[&str] = &["all", "env", "stage", "service", "recipe"];
const PROJECT_CFG_TEMPLATE: &str = "[global.params]
project = \"{{{ project_name }}}\"
##################
# Environments #
##################
[envs.dev]
params.is-prod = \"false\"
[envs.prod]
params.is-prod = \"true\"
##################
# Stages #
##################
[stages.build]
[stages.deploy]
depends_on = [\"build\"]
params.target = \"linux\"
envs = [\"dev\", \"prod\"]
";
#[derive(Serialize, Deserialize, Default)]
pub struct Project {
#[serde(skip)]
pub path: String,
#[serde(skip)]
pub services: Arc<RwLock<HashMap<String, Arc<Service>>>>,
#[serde(skip)]
pub recipes: Arc<RwLock<HashMap<String, Arc<Recipe>>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub global: Option<Section>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub envs: HashMap<String, Section>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub stages: HashMap<String, Stage>,
#[serde(default)]
pub store: Store,
}
impl Project {
pub fn create(project_path: &str, template: Option<&str>) -> Result<(), Error> {
debug!("Create project {}", project_path);
if project_path.contains("..") {
return Err(Error::CreateProjectError(
"illegal sequence in project name",
project_path.to_string(),
));
}
create_dir(project_path)?;
init_repo(project_path)?;
let template = match template {
Some(template) => Template::from_string(template),
None => {
let mut template_map = HashMap::new();
template_map.insert("orcs.toml".to_string(), PROJECT_CFG_TEMPLATE.to_string());
Template::from_map(template_map)
}
};
let mut template_data = HashMap::new();
let project_path = Path::new(project_path).canonicalize()?;
let project_name = project_path.file_name().unwrap().to_str().unwrap();
template_data.insert("project_name", &project_name);
template.render(project_path.as_path(), &template_data)?;
Ok(())
}
#[instrument]
pub fn from_path(project_path: &str) -> Result<Arc<Self>, Error> {
debug!("Load project");
let filename = Path::new(project_path).canonicalize()?.join("orcs.toml");
if !filename.is_file() {
return Err(Error::LoadProjectError("could not find the project file"));
}
debug!("Load project configuration file");
let mut file = File::open(&filename)?;
let mut data = String::new();
file.read_to_string(&mut data)?;
let extension = match filename.extension() {
Some(ext) => ext,
_ => return Err(Error::LoadProjectError("missing project file extension")),
}
.to_str();
debug!("Deserialize configuration file");
match extension {
Some("toml") => Self::from_toml(project_path, &data),
_ => Err(Error::LoadProjectError("unsupported extension")),
}
}
fn from_toml(project_path: &str, data: &str) -> Result<Arc<Self>, Error> {
let mut project: Self = toml::from_str(&data)?;
project.path = project_path.to_string();
project.validate()?;
Ok(Arc::new(project))
}
pub fn canonical_path(&self) -> std::path::PathBuf {
Path::new(&self.path)
.canonicalize()
.expect("Could not canonicalize path")
}
#[instrument(skip(self))]
fn validate(&self) -> Result<(), Error> {
debug!("Validate project configuration");
for reserved_value in RESERVED_VALUES {
if self.stages.contains_key(&(*reserved_value).to_string()) {
return Err(Error::CheckProjectError(
"invalid stage name",
self.path.clone(),
));
}
if self.envs.contains_key(&(*reserved_value).to_string()) {
return Err(Error::CheckProjectError(
"invalid environment name",
self.path.clone(),
));
}
}
for stage in self.stages.values() {
for stage_env in &stage.envs {
if !self.envs.contains_key(stage_env) {
return Err(Error::CheckProjectError(
"env in stage does not exist",
stage_env.clone(),
));
}
}
for stage_dep in &stage.depends_on {
if !self.stages.contains_key(stage_dep) {
return Err(Error::CheckProjectError(
"stage in depends_on does not exist",
stage_dep.clone(),
));
}
if self.stages.get(stage_dep).unwrap().skip {
return Err(Error::CheckProjectError(
"stage in depends_on cannot be skipped",
stage_dep.clone(),
));
}
}
}
Ok(())
}
}
impl PartialEq for Project {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
impl fmt::Debug for Project {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Project")
.field("path", &self.path)
.field("global", &self.global)
.field("envs", &self.envs)
.field("stages", &self.stages)
.field("store", &self.store)
.finish()
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Section {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub params: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Stage {
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub skip: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub params: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub envs: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Store {
#[serde(rename = "type")]
pub store_type: String,
}
impl Default for Store {
fn default() -> Self {
Self {
store_type: "local".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::prelude::*;
#[test]
fn validate_reserved_stage_name() {
let mut rng = rand::thread_rng();
let reserved_value = RESERVED_VALUES.choose(&mut rng).unwrap();
let data = format!("[stages.{}]", reserved_value);
match Project::from_toml(".", &data) {
Err(Error::CheckProjectError(_, _)) => assert!(true),
_ => assert!(
false,
"validate should fail for reserved value in stage name"
),
}
}
#[test]
fn validate_reserved_env_name() {
let mut rng = rand::thread_rng();
let reserved_value = RESERVED_VALUES.choose(&mut rng).unwrap();
let data = format!("[envs.{}]", reserved_value);
match Project::from_toml(".", &data) {
Err(Error::CheckProjectError(_, _)) => assert!(true),
_ => assert!(false, "validate should fail for reserved value in env name"),
}
}
#[test]
fn validate_missing_env_in_stage() {
let data = "[stages.missing_env]
envs = [\"missing_env\"]"
.to_string();
match Project::from_toml(".", &data) {
Err(Error::CheckProjectError(_, _)) => assert!(true),
_ => assert!(false, "validate should fail for missing env in stage"),
}
}
#[test]
fn validate_missing_dependency_in_stage() {
let data = "[stages.test_dep]
depends_on = [\"missing_dep\"]"
.to_string();
match Project::from_toml(".", &data) {
Err(Error::CheckProjectError(_, _)) => assert!(true),
_ => assert!(
false,
"validate should fail for missing dependency in stage"
),
}
}
#[test]
fn validate_skipped_dependency_in_stage() {
let data = "[stages.test_dep]
depends_on = [\"skipped_dep\"]
[stages.skipped_dep]
skip = true"
.to_string();
match Project::from_toml(".", &data) {
Err(Error::CheckProjectError(_, _)) => assert!(true),
_ => assert!(
false,
"validate should fail for skipped dependency in stage"
),
}
}
}