use crate::core::api::ApiError;
use anyhow::{Context, Result};
use colored::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Manifest {
pub name: String,
pub version: String,
#[serde(default = "default_spec")]
pub default_spec: String,
#[serde(default = "default_env")]
pub default_env: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn default_spec() -> String {
"specs/api.yaml".to_string()
}
fn default_env() -> String {
"development".to_string()
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct EnvironmentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_retries")]
pub retries: u32,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate_limit: Option<RateLimitConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
}
fn default_timeout() -> u64 {
30000 }
fn default_retries() -> u32 {
2
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AuthConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred: Option<String>,
#[serde(default)]
pub schemes: HashMap<String, AuthScheme>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum AuthScheme {
ApiKey {
#[serde(rename = "in")]
location: String,
name: String,
value: String,
},
Bearer {
token: String,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
Basic {
username: String,
password: String,
},
OAuth2 {
client_id: String,
client_secret: String,
token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
scopes: Option<Vec<String>>,
},
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RateLimitConfig {
pub requests_per_second: u32,
#[serde(default = "default_burst")]
pub burst: u32,
}
fn default_burst() -> u32 {
10
}
pub struct ConfigLoader {
#[allow(dead_code)]
manifest: Manifest,
environment: String,
config: EnvironmentConfig,
env_vars: HashMap<String, String>,
}
impl ConfigLoader {
pub fn list_environments() -> Vec<String> {
let mut environments = Vec::new();
let config_dir = PathBuf::from("config");
if let Ok(entries) = fs::read_dir(&config_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".yaml") && name != "default.yaml" {
let env_name = &name[..name.len() - 5];
environments.push(env_name.to_string());
}
}
}
}
environments.sort();
environments
}
pub fn environment_exists(env: &str) -> bool {
let config_file = PathBuf::from(format!("config/{}.yaml", env));
config_file.exists()
}
fn find_similar_environments(env: &str) -> Vec<String> {
let available = Self::list_environments();
let env_lower = env.to_lowercase();
let mut suggestions = Vec::new();
for avail in &available {
if avail.to_lowercase() == env_lower {
suggestions.insert(0, avail.clone()); }
else if avail.contains(env) || env.contains(avail) {
suggestions.push(avail.clone());
}
else if Self::is_similar(env, avail) {
suggestions.push(avail.clone());
}
}
suggestions
}
fn is_similar(s1: &str, s2: &str) -> bool {
if (s1.len() as i32 - s2.len() as i32).abs() > 2 {
return false;
}
let mut differences = 0;
let chars1: Vec<char> = s1.chars().collect();
let chars2: Vec<char> = s2.chars().collect();
let min_len = chars1.len().min(chars2.len());
for i in 0..min_len {
if chars1[i] != chars2[i] {
differences += 1;
if differences > 2 {
return false;
}
}
}
differences + (chars1.len() as i32 - chars2.len() as i32).abs() as usize <= 2
}
pub fn validate_environment(env: &str, default_env: Option<&str>) -> Result<String> {
if Self::environment_exists(env) {
return Ok(env.to_string());
}
let available = Self::list_environments();
if available.is_empty() {
return Err(ApiError::ValidationError(format!(
"No environment configurations found in config/ directory.\n\
\n\
To create an environment configuration:\n\
mkdir -p config\n\
echo \"base_url: https://api.example.com\" > config/{}.yaml\n\
\n\
Or run 'mrapids init' to set up a new project with default environments.",
env
))
.into());
}
let suggestions = Self::find_similar_environments(env);
if !suggestions.is_empty() {
let mut msg = format!("Environment '{}' not found.", env.bright_red());
if suggestions[0].to_lowercase() == env.to_lowercase() {
msg.push_str(&format!(
"\n\n{} Configuration names are case-sensitive.\n",
"Note:".bright_yellow()
));
msg.push_str(&format!(
"Did you mean '{}'?",
suggestions[0].bright_green()
));
} else {
msg.push_str(&format!(
"\n\nDid you mean {}?",
if suggestions.len() == 1 {
format!("'{}'", suggestions[0].bright_green())
} else {
format!(
"one of: {}",
suggestions
.iter()
.map(|s| format!("'{}'", s.bright_green()))
.collect::<Vec<_>>()
.join(", ")
)
}
));
}
msg.push_str(&format!(
"\n\n{} {}\n",
"Available environments:".bold(),
available
.iter()
.map(|e| {
if Some(e.as_str()) == default_env {
format!("{} (default)", e.bright_cyan())
} else {
e.to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
));
msg.push_str("\nRun 'mrapids env list' for more details.");
return Err(ApiError::ValidationError(msg).into());
} else {
return Err(ApiError::ValidationError(format!(
"Environment '{}' not found.\n\n\
{} {}\n\n\
Run 'mrapids env list' for more details.",
env.bright_red(),
"Available environments:".bold(),
available.join(", ")
))
.into());
}
}
pub fn load(environment: Option<&str>) -> Result<Self> {
let manifest = Self::load_manifest()?;
let env = environment
.map(|s| s.to_string())
.unwrap_or_else(|| manifest.default_env.clone());
let validated_env = Self::validate_environment(&env, Some(&manifest.default_env));
let env = match validated_env {
Ok(valid_env) => valid_env,
Err(e) => {
eprintln!("{}", e);
eprintln!(
"\n{} Using default configuration.",
"Continuing:".bright_yellow()
);
"development".to_string()
}
};
eprintln!(
"🔧 Loading configuration for environment: {}",
env.bright_cyan()
);
let config = Self::load_environment_config(&env)?;
let env_vars = Self::load_env_file(&env)?;
let mut loader = Self {
manifest,
environment: env,
config,
env_vars,
};
loader.resolve_variables()?;
Ok(loader)
}
fn load_manifest() -> Result<Manifest> {
let path = PathBuf::from("mrapids.yaml");
if !path.exists() {
return Err(ApiError::ValidationError(
"No mrapids.yaml found in current directory.\n\
Run 'mrapids init' to create a new project."
.to_string(),
)
.into());
}
let content = fs::read_to_string(&path).context("Failed to read mrapids.yaml")?;
let manifest: Manifest =
serde_yaml::from_str(&content).context("Failed to parse mrapids.yaml")?;
Ok(manifest)
}
fn load_environment_config(env: &str) -> Result<EnvironmentConfig> {
let mut config = EnvironmentConfig::default();
let default_path = PathBuf::from("config/default.yaml");
if default_path.exists() {
let content =
fs::read_to_string(&default_path).context("Failed to read config/default.yaml")?;
if !content.trim().is_empty() {
config = serde_yaml::from_str(&content)
.context("Failed to parse config/default.yaml")?;
}
}
let env_path = PathBuf::from(format!("config/{}.yaml", env));
if env_path.exists() {
let content = fs::read_to_string(&env_path)
.with_context(|| format!("Failed to read config/{}.yaml", env))?;
if !content.trim().is_empty() {
let env_config: EnvironmentConfig = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config/{}.yaml", env))?;
config = Self::merge_configs(config, env_config);
}
} else if env != "default" {
eprintln!("⚠️ No config/{}.yaml found, using defaults", env);
}
Ok(config)
}
fn load_env_file(env: &str) -> Result<HashMap<String, String>> {
let mut vars = HashMap::new();
let env_file = PathBuf::from(format!("env/.env.{}", env));
if env_file.exists() {
let content = fs::read_to_string(&env_file)
.with_context(|| format!("Failed to read env/.env.{}", env))?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().trim_matches('"').to_string();
vars.insert(key, value);
}
}
}
for (key, value) in env::vars() {
vars.insert(key, value);
}
Ok(vars)
}
fn merge_configs(mut base: EnvironmentConfig, overlay: EnvironmentConfig) -> EnvironmentConfig {
if overlay.base_url.is_some() {
base.base_url = overlay.base_url;
}
if overlay.timeout_ms != default_timeout() {
base.timeout_ms = overlay.timeout_ms;
}
if overlay.retries != default_retries() {
base.retries = overlay.retries;
}
base.headers.extend(overlay.headers);
if overlay.auth.is_some() {
base.auth = overlay.auth;
}
if overlay.rate_limit.is_some() {
base.rate_limit = overlay.rate_limit;
}
if overlay.proxy.is_some() {
base.proxy = overlay.proxy;
}
base
}
fn resolve_variables(&mut self) -> Result<()> {
if let Some(url) = &self.config.base_url {
let resolved = self.resolve_string(url)?;
self.config.base_url = Some(resolved);
}
if self
.config
.base_url
.as_deref()
.map_or(true, |u| u.is_empty() || u.contains("${"))
{
let env_prefix = self.environment.to_uppercase().replace("-", "_");
let fallback_vars = [
format!("{}_BASE_URL", env_prefix),
"API_BASE_URL".to_string(),
"MRAPIDS_BASE_URL".to_string(),
];
for var_name in &fallback_vars {
if let Ok(url) = std::env::var(var_name) {
if !url.is_empty() {
self.config.base_url = Some(url);
break;
}
}
}
}
let headers = self.config.headers.clone();
self.config.headers.clear();
for (key, value) in headers {
let resolved_value = self.resolve_string(&value)?;
self.config.headers.insert(key, resolved_value);
}
if let Some(auth) = self.config.auth.clone() {
let mut resolved_schemes = HashMap::new();
for (name, scheme) in auth.schemes {
let resolved_scheme = match scheme {
AuthScheme::ApiKey {
location,
name,
value,
} => AuthScheme::ApiKey {
location,
name,
value: self.resolve_string(&value)?,
},
AuthScheme::Bearer { token, format } => AuthScheme::Bearer {
token: self.resolve_string(&token)?,
format,
},
AuthScheme::Basic { username, password } => AuthScheme::Basic {
username: self.resolve_string(&username)?,
password: self.resolve_string(&password)?,
},
AuthScheme::OAuth2 {
client_id,
client_secret,
token_url,
scopes,
} => AuthScheme::OAuth2 {
client_id: self.resolve_string(&client_id)?,
client_secret: self.resolve_string(&client_secret)?,
token_url,
scopes,
},
};
resolved_schemes.insert(name, resolved_scheme);
}
self.config.auth = Some(AuthConfig {
preferred: auth.preferred,
schemes: resolved_schemes,
});
}
if let Some(proxy) = &self.config.proxy {
let resolved = self.resolve_string(proxy)?;
self.config.proxy = Some(resolved);
}
Ok(())
}
fn resolve_string(&self, input: &str) -> Result<String> {
let re = Regex::new(r"\$\{([^}]+)\}").unwrap();
let mut result = input.to_string();
for cap in re.captures_iter(input) {
let var_name = &cap[1];
let full_match = &cap[0];
if let Some(value) = self.env_vars.get(var_name) {
result = result.replace(full_match, value);
} else {
if let Some((var, default)) = var_name.split_once(":-") {
if let Some(value) = self.env_vars.get(var) {
result = result.replace(full_match, value);
} else {
result = result.replace(full_match, default);
}
} else {
eprintln!("⚠️ Warning: Variable ${} not found", var_name);
}
}
}
Ok(result)
}
#[allow(dead_code)]
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn environment(&self) -> &str {
&self.environment
}
#[allow(dead_code)]
pub fn config(&self) -> &EnvironmentConfig {
&self.config
}
pub fn base_url(&self) -> Option<&str> {
self.config.base_url.as_deref()
}
pub fn timeout_ms(&self) -> u64 {
self.config.timeout_ms
}
#[allow(dead_code)]
pub fn retries(&self) -> u32 {
self.config.retries
}
pub fn headers(&self) -> &HashMap<String, String> {
&self.config.headers
}
pub fn auth(&self) -> Option<&AuthConfig> {
self.config.auth.as_ref()
}
}
#[allow(dead_code)]
pub fn load_auth_config() -> Result<HashMap<String, AuthScheme>> {
let path = PathBuf::from("auth.yaml");
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path).context("Failed to read auth.yaml")?;
let auth: HashMap<String, AuthScheme> =
serde_yaml::from_str(&content).context("Failed to parse auth.yaml")?;
Ok(auth)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_variable_resolution() {
let loader = ConfigLoader {
manifest: Manifest {
name: "test".to_string(),
version: "1.0.0".to_string(),
default_spec: "specs/api.yaml".to_string(),
default_env: "dev".to_string(),
description: None,
},
environment: "dev".to_string(),
config: EnvironmentConfig::default(),
env_vars: HashMap::from([
("API_KEY".to_string(), "secret123".to_string()),
(
"BASE_URL".to_string(),
"https://api.example.com".to_string(),
),
]),
};
let result = loader.resolve_string("Bearer ${API_KEY}").unwrap();
assert_eq!(result, "Bearer secret123");
let result = loader.resolve_string("${BASE_URL}/v1").unwrap();
assert_eq!(result, "https://api.example.com/v1");
let result = loader.resolve_string("${MISSING:-default}").unwrap();
assert_eq!(result, "default");
}
}