use std::{path::PathBuf, sync::OnceLock};
use crate::domain::{error::DotLockError, model::DotLockResult};
pub const DOTLOCK_DIR: &str = ".lock";
pub const ENVS_DIR: &str = ".lock/envs";
pub const DEFAULT_ENV_NAME: &str = "default";
pub const ENV_SELECTION_FILE: &str = ".lock/env";
const DEFAULT_VAULT_FILE: &str = ".lock/vault.toml";
const DEFAULT_SECRETS_FILE: &str = ".lock/secrets.lock";
static SELECTED_ENV: OnceLock<Option<String>> = OnceLock::new();
pub fn init_env_selection(cli_env: Option<String>) -> DotLockResult<()> {
let resolved = resolve_env_selection(cli_env)?;
let _ = SELECTED_ENV.set(resolved);
Ok(())
}
fn resolve_env_selection(cli_env: Option<String>) -> DotLockResult<Option<String>> {
let explicit = cli_env.or_else(|| {
std::env::var("DOTLOCK_ENV")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
});
if let Some(name) = explicit {
validate_env_name(&name)?;
return Ok(normalize_default(name));
}
match persisted_default_env() {
Some(name) => {
validate_env_name(&name)?;
Ok(normalize_default(name))
}
None => Ok(None),
}
}
fn normalize_default(name: String) -> Option<String> {
(name != DEFAULT_ENV_NAME).then_some(name)
}
pub fn active_env() -> Option<String> {
if let Some(selected) = SELECTED_ENV.get() {
return selected.clone();
}
resolve_env_selection(None).ok().flatten()
}
pub fn active_env_name() -> String {
active_env().unwrap_or_else(|| DEFAULT_ENV_NAME.to_string())
}
pub fn validate_env_name(name: &str) -> DotLockResult<()> {
let valid = !name.is_empty()
&& name.len() <= 32
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& name.starts_with(|c: char| c.is_ascii_alphanumeric());
if valid {
Ok(())
} else {
Err(DotLockError::InvalidEnvironmentName {
name: name.to_string(),
})
}
}
pub fn env_lock_dir_for(env: Option<&str>) -> String {
match env {
Some(name) => format!("{ENVS_DIR}/{name}"),
None => DOTLOCK_DIR.to_string(),
}
}
pub fn vault_file_for(env: Option<&str>) -> String {
match env {
Some(name) => format!("{ENVS_DIR}/{name}/vault.toml"),
None => DEFAULT_VAULT_FILE.to_string(),
}
}
pub fn secrets_file_for(env: Option<&str>) -> String {
match env {
Some(name) => format!("{ENVS_DIR}/{name}/secrets.lock"),
None => DEFAULT_SECRETS_FILE.to_string(),
}
}
pub fn vault_file() -> String {
vault_file_for(active_env().as_deref())
}
pub fn secrets_file() -> String {
secrets_file_for(active_env().as_deref())
}
pub fn env_lock_dir() -> String {
env_lock_dir_for(active_env().as_deref())
}
pub fn list_environments() -> Vec<String> {
let mut envs = Vec::new();
if PathBuf::from(DEFAULT_VAULT_FILE).exists() {
envs.push(DEFAULT_ENV_NAME.to_string());
}
let mut named: Vec<String> = std::fs::read_dir(ENVS_DIR)
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| validate_env_name(name).is_ok())
.filter(|name| PathBuf::from(vault_file_for(Some(name))).exists())
.collect()
})
.unwrap_or_default();
named.sort();
envs.extend(named);
envs
}
pub fn persisted_default_env() -> Option<String> {
let content = std::fs::read_to_string(ENV_SELECTION_FILE).ok()?;
let name = content.trim().to_string();
(!name.is_empty()).then_some(name)
}
pub fn is_project_initialized() -> bool {
PathBuf::from(vault_file()).exists()
}
pub fn ensure_project_initialized() -> DotLockResult<()> {
if is_project_initialized() {
return Ok(());
}
if let Some(env) = active_env()
&& PathBuf::from(DEFAULT_VAULT_FILE).exists()
{
return Err(DotLockError::EnvironmentNotInitialized { name: env });
}
Err(DotLockError::ProjectNotInitialized)
}
pub fn ensure_project_not_initialized() -> DotLockResult<()> {
if is_project_initialized() {
return Err(DotLockError::ProjectAlreadyInitialized);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_names_are_validated_as_safe_path_components() {
for good in ["dev", "staging", "prod-eu", "qa_2", "a", "default"] {
assert!(validate_env_name(good).is_ok(), "{good} must be valid");
}
for bad in [
"",
"../etc",
"a/b",
".hidden",
"-lead",
"_lead",
"name with space",
"über",
"x".repeat(33).as_str(),
] {
assert!(
matches!(
validate_env_name(bad),
Err(DotLockError::InvalidEnvironmentName { .. })
),
"{bad:?} must be rejected"
);
}
}
#[test]
fn default_env_keeps_the_legacy_paths_and_named_envs_are_scoped() {
assert_eq!(vault_file_for(None), ".lock/vault.toml");
assert_eq!(secrets_file_for(None), ".lock/secrets.lock");
assert_eq!(env_lock_dir_for(None), ".lock");
assert_eq!(
vault_file_for(Some("staging")),
".lock/envs/staging/vault.toml"
);
assert_eq!(
secrets_file_for(Some("staging")),
".lock/envs/staging/secrets.lock"
);
assert_eq!(env_lock_dir_for(Some("staging")), ".lock/envs/staging");
}
#[test]
fn explicit_default_name_resolves_to_the_legacy_environment() {
assert_eq!(
resolve_env_selection(Some("default".to_string())).expect("resolve"),
None
);
assert_eq!(
resolve_env_selection(Some("staging".to_string())).expect("resolve"),
Some("staging".to_string())
);
assert!(matches!(
resolve_env_selection(Some("../evil".to_string())),
Err(DotLockError::InvalidEnvironmentName { .. })
));
}
}