use crate::config::types::SolarboatConfig;
use crate::utils::logger;
use anyhow::{Context, Result};
use serde_json;
use std::path::{Path, PathBuf};
use std::env;
const CONFIG_FILE_NAMES: &[&str] = &[
"solarboat.json",
];
pub struct ConfigLoader {
pub search_dir: PathBuf,
}
impl ConfigLoader {
pub fn new<P: AsRef<Path>>(search_dir: P) -> Self {
Self {
search_dir: search_dir.as_ref().to_path_buf(),
}
}
pub fn from_current_dir() -> Result<Self> {
let current_dir = std::env::current_dir()
.context("Failed to get current working directory")?;
Ok(Self::new(current_dir))
}
pub fn load(&self) -> Result<Option<SolarboatConfig>> {
let config_path = self.find_config_file()?;
match config_path {
Some(path) => {
logger::config_loading(&path.display().to_string());
let config = self.load_from_path(&path)?;
Ok(Some(config))
}
None => {
logger::info("No configuration file found, using defaults");
Ok(None)
}
}
}
pub fn load_from_path<P: AsRef<Path>>(&self, path: P) -> Result<SolarboatConfig> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read configuration file: {}", path.display()))?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => {
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON configuration: {}", path.display()))
}
_ => {
if content.trim().starts_with('{') {
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON configuration: {}", path.display()))
} else {
Err(anyhow::anyhow!("Unsupported configuration file format. Only JSON files are supported."))
}
}
}
}
fn find_config_file(&self) -> Result<Option<PathBuf>> {
let mut search_order = Vec::new();
if let Ok(env) = env::var("SOLARBOAT_ENV") {
if !env.trim().is_empty() {
search_order.push(format!("solarboat.{}.json", env));
}
}
for &filename in CONFIG_FILE_NAMES {
search_order.push(filename.to_string());
}
for filename in search_order {
let config_path = self.search_dir.join(&filename);
if config_path.exists() {
if let Ok(env) = env::var("SOLARBOAT_ENV") {
if !env.trim().is_empty() && filename.contains(&env) {
logger::info(&format!("Detected SOLARBOAT_ENV='{}', loading environment-specific config", env));
}
}
return Ok(Some(config_path));
}
}
Ok(None)
}
pub fn validate_config(&self, config: &SolarboatConfig) -> Result<()> {
let validation_errors: Vec<String> = Vec::new();
let mut validation_warnings: Vec<String> = Vec::new();
for module_path in config.modules.keys() {
let full_path = self.search_dir.join(module_path);
if !full_path.exists() {
validation_warnings.push(format!("Module path '{}' does not exist (checked: {})",
module_path, full_path.display()));
}
}
if let Some(workspace_files) = &config.global.workspace_var_files {
for (workspace, files) in &workspace_files.workspaces {
self.validate_var_files(files, &format!("global workspace '{}'", workspace), &mut validation_warnings)?;
}
}
for (module_path, module_config) in &config.modules {
if let Some(workspace_files) = &module_config.workspace_var_files {
for (workspace, files) in &workspace_files.workspaces {
self.validate_var_files(files, &format!("module '{}' workspace '{}'", module_path, workspace), &mut validation_warnings)?;
}
}
}
self.validate_workspace_names(config, &mut validation_warnings)?;
if !validation_warnings.is_empty() {
logger::config_validation_warnings(&validation_warnings);
}
logger::config_validation_summary(validation_warnings.len(), validation_errors.len());
if !validation_errors.is_empty() {
logger::error_box("Configuration Validation Failed", &format!("Configuration validation failed with {} error(s)", validation_errors.len()));
for error in &validation_errors {
logger::error(&format!(" • {}", error));
}
return Err(anyhow::anyhow!("Configuration validation failed with {} error(s)", validation_errors.len()));
}
Ok(())
}
fn validate_var_files(&self, var_files: &[String], context: &str, warnings: &mut Vec<String>) -> Result<()> {
for var_file in var_files {
let var_path = if Path::new(var_file).is_absolute() {
PathBuf::from(var_file)
} else {
if context.starts_with("module") {
let module_path = context.split("'").nth(1).unwrap_or("");
let module_dir = self.search_dir.join(module_path);
module_dir.join(var_file)
} else {
self.search_dir.join(var_file)
}
};
if !var_path.exists() {
warnings.push(format!("Var file '{}' for {} does not exist (checked: {})",
var_file, context, var_path.display()));
}
}
Ok(())
}
fn validate_workspace_names(&self, config: &SolarboatConfig, warnings: &mut Vec<String>) -> Result<()> {
let reserved_names = ["terraform"];
if let Some(workspace_files) = &config.global.workspace_var_files {
for workspace in workspace_files.workspaces.keys() {
if reserved_names.contains(&workspace.as_str()) {
warnings.push(format!("Workspace name '{}' is reserved and may cause issues", workspace));
}
}
}
for (module_path, module_config) in &config.modules {
if let Some(workspace_files) = &module_config.workspace_var_files {
if workspace_files.workspaces.len() > 1 && workspace_files.workspaces.contains_key("default") {
warnings.push(format!("Workspace name 'default' in module '{}' is configured alongside other workspaces - this may cause confusion", module_path));
}
for workspace in workspace_files.workspaces.keys() {
if reserved_names.contains(&workspace.as_str()) {
warnings.push(format!("Workspace name '{}' in module '{}' is reserved and may cause issues",
workspace, module_path));
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
#[test]
fn test_load_json_config() {
let temp_dir = TempDir::new().unwrap();
let config_content = r#"{
"global": {
"ignore_workspaces": ["dev", "test"],
"workspace_var_files": {
"default": ["global.tfvars"]
}
},
"modules": {
"infrastructure/networking": {
"ignore_workspaces": ["dev"],
"workspace_var_files": {
"default": ["networking.tfvars"]
}
}
}
}"#;
fs::write(temp_dir.path().join("solarboat.json"), config_content).unwrap();
let loader = ConfigLoader::new(temp_dir.path());
let config = loader.load().unwrap().unwrap();
assert_eq!(config.global.ignore_workspaces, vec!["dev", "test"]);
assert!(config.global.workspace_var_files.is_some());
assert!(config.modules.contains_key("infrastructure/networking"));
}
#[test]
fn test_unsupported_file_format() {
let temp_dir = TempDir::new().unwrap();
let config_content = r#"
global:
ignore_workspaces:
- dev
- test
workspace_var_files:
default:
- global.tfvars
"#;
fs::write(temp_dir.path().join("solarboat.yml"), config_content).unwrap();
let loader = ConfigLoader::new(temp_dir.path());
let result = loader.load_from_path(temp_dir.path().join("solarboat.yml"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unsupported configuration file format"));
}
#[test]
fn test_no_config_file() {
let temp_dir = TempDir::new().unwrap();
let loader = ConfigLoader::new(temp_dir.path());
let config = loader.load().unwrap();
assert!(config.is_none());
}
}