use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct EnvSet {
pub name: Option<String>,
pub vars: HashMap<String, String>,
}
impl EnvSet {
pub fn empty() -> Self {
Self::default()
}
pub fn load(workspace: &Path, name: &str) -> Self {
let mut vars = HashMap::new();
for sub in [".rqst", ".mnml"] {
let path = workspace.join(sub).join("env").join(format!("{name}.env"));
if let Ok(text) = fs::read_to_string(&path) {
for line in text.lines() {
if let Some((k, v)) = parse_env_line(line) {
vars.insert(k, v);
}
}
}
}
Self {
name: Some(name.to_string()),
vars,
}
}
pub fn select(workspace: &Path, explicit: Option<&str>) -> Self {
Self::select_with_config_default(workspace, explicit, None)
}
pub fn select_with_config_default(
workspace: &Path,
explicit: Option<&str>,
config_default: Option<&str>,
) -> Self {
let name = explicit
.map(str::to_string)
.or_else(|| std::env::var("MNML_ENV").ok())
.or_else(|| config_default.map(str::to_string))
.or_else(|| read_rqst_config_default_env(workspace))
.filter(|s| !s.trim().is_empty());
match name {
Some(n) => Self::load(workspace, &n),
None => Self::empty(),
}
}
pub fn select_with_full_fallback(
workspace: &Path,
explicit: Option<&str>,
config_default: Option<&str>,
) -> Self {
let name = explicit
.map(str::to_string)
.or_else(|| std::env::var("MNML_ENV").ok())
.or_else(|| config_default.map(str::to_string))
.or_else(|| read_mnml_config_default_env(workspace))
.or_else(|| read_rqst_config_default_env(workspace))
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "dev".to_string());
Self::load(workspace, &name)
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn lookup(&self, key: &str) -> Option<String> {
if let Some(v) = self.vars.get(key) {
return Some(v.clone());
}
std::env::var(key).ok()
}
}
pub fn dynamic_var(name: &str) -> Option<String> {
match name {
"uuid" | "guid" => Some(uuid_v4()),
"timestamp" | "epoch" | "epochS" => Some((unix_ms() / 1000).to_string()),
"epochMs" => Some(unix_ms().to_string()),
"isoTimestamp" | "isoTime" | "nowIso" => Some(iso_utc_now()),
"randomInt" => Some((small_random_u32() % 1_000_000).to_string()),
"randomHex" => Some(format!("{:08x}", small_random_u32())),
"randomString" => Some(uuid_v4().replace('-', "")[..16].to_string()),
"randomBool" => Some(
if small_random_u32().is_multiple_of(2) {
"true"
} else {
"false"
}
.to_string(),
),
_ => None,
}
}
fn iso_utc_now() -> String {
let ns = unix_ns();
let secs = (ns / 1_000_000_000) as i64;
let frac_ns = (ns % 1_000_000_000) as u32;
let days = secs.div_euclid(86_400);
let seconds_of_day = secs.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
let h = seconds_of_day / 3600;
let mm = (seconds_of_day % 3600) / 60;
let ss = seconds_of_day % 60;
format!(
"{y:04}-{m:02}-{d:02}T{h:02}:{mm:02}:{ss:02}.{sub:07}Z",
sub = frac_ns / 100
)
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 {
z / 146_097
} else {
(z - 146_096) / 146_097
};
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i32 + era as i32 * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + if m <= 2 { 1 } else { 0 };
(y, m, d)
}
pub fn expand(text: &str, env: &EnvSet) -> String {
let mut out = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len()
&& bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = text[i + 2..].find("}}")
{
let name = text[i + 2..i + 2 + end_off].trim();
if is_valid_var_name(name)
&& let Some(value) = resolve(name, env)
{
out.push_str(&value);
i += 2 + end_off + 2;
continue;
}
}
let c = text[i..].chars().next().unwrap();
out.push(c);
i += c.len_utf8();
}
out
}
pub fn unresolved(text: &str, env: &EnvSet) -> Vec<String> {
let mut missing: Vec<String> = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len()
&& bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = text[i + 2..].find("}}")
{
let name = text[i + 2..i + 2 + end_off].trim();
if is_valid_var_name(name)
&& resolve(name, env).is_none()
&& !missing.iter().any(|m| m == name)
{
missing.push(name.to_string());
}
i += 2 + end_off + 2;
continue;
}
let c = text[i..].chars().next().unwrap();
i += c.len_utf8();
}
missing
}
fn resolve(name: &str, env: &EnvSet) -> Option<String> {
match name.strip_prefix('$') {
Some(dyn_name) => dynamic_var(dyn_name),
None => env.lookup(name),
}
}
fn is_valid_var_name(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if first != '$' && !first.is_ascii_alphanumeric() && first != '_' {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn read_rqst_config_default_env(workspace: &Path) -> Option<String> {
let text = fs::read_to_string(workspace.join(".rqst").join("config")).ok()?;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((k, v)) = trimmed.split_once('=')
&& k.trim() == "default_env"
&& !v.trim().is_empty()
{
return Some(v.trim().to_string());
}
}
None
}
fn read_mnml_config_default_env(workspace: &Path) -> Option<String> {
fn read_one(path: &Path) -> Option<String> {
let text = fs::read_to_string(path).ok()?;
let mut in_http_section = false;
for line in text.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
if l.starts_with('[') && l.ends_with(']') {
in_http_section = l == "[http]";
continue;
}
if in_http_section
&& let Some((k, v)) = l.split_once('=')
&& k.trim() == "default_env"
{
let v = v.trim();
let stripped = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
if !stripped.trim().is_empty() {
return Some(stripped.to_string());
}
}
}
None
}
read_one(&workspace.join(".mnml").join("config.toml")).or_else(|| {
std::env::var_os("HOME").and_then(|home| {
let path = std::path::PathBuf::from(home)
.join(".config")
.join("mnml")
.join("config.toml");
read_one(&path)
})
})
}
fn parse_env_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let payload = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let (k, v) = payload.split_once('=')?;
let key = k.trim().to_string();
if key.is_empty() {
return None;
}
let v = v.trim();
let value = if v.len() >= 2
&& ((v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\'')))
{
v[1..v.len() - 1].to_string()
} else {
v.to_string()
};
Some((key, value))
}
fn uuid_v4() -> String {
let mut b = random_bytes(16);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let mut s = String::with_capacity(36);
for (i, byte) in b.iter().enumerate() {
if matches!(i, 4 | 6 | 8 | 10) {
s.push('-');
}
s.push_str(&format!("{byte:02x}"));
}
s
}
fn random_bytes(n: usize) -> Vec<u8> {
let mut buf = vec![0u8; n];
if let Ok(mut f) = fs::File::open("/dev/urandom")
&& f.read_exact(&mut buf).is_ok()
{
return buf;
}
let mut seed = unix_ns() ^ ((std::process::id() as u128) << 64);
for chunk in buf.chunks_mut(8) {
seed = splitmix64(seed);
let bytes = (seed as u64).to_le_bytes();
for (i, b) in chunk.iter_mut().enumerate() {
*b = bytes[i];
}
}
buf
}
fn small_random_u32() -> u32 {
let b = random_bytes(4);
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
fn splitmix64(mut z: u128) -> u128 {
z = z.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut x = z as u64;
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^= x >> 31;
x as u128
}
fn unix_ms() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
fn unix_ns() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> EnvSet {
EnvSet {
name: None,
vars: pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn expand_substitutes_known_leaves_unknown() {
let e = env(&[("BASE_URL", "https://api.example.com"), ("TOKEN", "abc")]);
assert_eq!(
expand("{{BASE_URL}}/users?t={{TOKEN}}&x={{MISSING}}", &e),
"https://api.example.com/users?t=abc&x={{MISSING}}"
);
assert_eq!(expand("{{ BASE_URL }}", &e), "https://api.example.com");
}
#[test]
fn unresolved_lists_missing_in_order_deduped() {
let e = env(&[("A", "1")]);
assert_eq!(unresolved("{{A}} {{B}} {{C}} {{B}}", &e), vec!["B", "C"]);
assert!(unresolved("{{A}}", &e).is_empty());
}
#[test]
fn dynamic_vars_expand_and_uuid_is_shaped() {
let e = EnvSet::empty();
let out = expand("{{$uuid}}", &e);
assert_eq!(out.len(), 36);
assert_eq!(out.matches('-').count(), 4);
assert!(out.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
assert_eq!(expand("{{$nope}}", &e), "{{$nope}}");
assert!(
expand("{{$randomInt}}", &e)
.chars()
.all(|c| c.is_ascii_digit())
);
}
#[test]
fn timestamp_returns_seconds_epoch_ms_returns_ms() {
let e = EnvSet::empty();
let ts = expand("{{$timestamp}}", &e);
let ms = expand("{{$epochMs}}", &e);
assert!(ts.chars().all(|c| c.is_ascii_digit()), "ts={ts}");
assert!(ms.chars().all(|c| c.is_ascii_digit()), "ms={ms}");
assert!(
ts.len() == 10 || ts.len() == 11,
"expected seconds (~10 digits), got {ts} ({} chars)",
ts.len()
);
assert!(
ms.len() >= 13,
"expected milliseconds (13+ digits), got {ms}"
);
let ep = expand("{{$epoch}}", &e);
assert_eq!(ep.len(), ts.len(), "$epoch should match $timestamp shape");
}
#[test]
fn iso_timestamp_matches_dotnet_shape() {
let e = EnvSet::empty();
let out = expand("{{$isoTimestamp}}", &e);
assert_eq!(out.len(), 28, "unexpected length: {out}");
assert_eq!(&out[4..5], "-");
assert_eq!(&out[7..8], "-");
assert_eq!(&out[10..11], "T");
assert_eq!(&out[13..14], ":");
assert_eq!(&out[16..17], ":");
assert_eq!(&out[19..20], ".");
assert!(out.ends_with('Z'));
let a = expand("{{$isoTimestamp}}", &e);
std::thread::sleep(std::time::Duration::from_millis(2));
let b = expand("{{$isoTimestamp}}", &e);
assert!(a <= b, "{a} vs {b}");
}
#[test]
fn parse_env_line_handles_quotes_and_comments() {
assert_eq!(
parse_env_line("FOO=bar"),
Some(("FOO".into(), "bar".into()))
);
assert_eq!(
parse_env_line("FOO = \"hello world\""),
Some(("FOO".into(), "hello world".into()))
);
assert_eq!(parse_env_line("# comment"), None);
assert_eq!(parse_env_line("=oops"), None);
assert_eq!(parse_env_line(""), None);
}
#[test]
fn parse_env_line_accepts_export_prefix() {
assert_eq!(
parse_env_line("export FOO=bar"),
Some(("FOO".into(), "bar".into()))
);
assert_eq!(
parse_env_line(" export API_KEY=\"abc\""),
Some(("API_KEY".into(), "abc".into()))
);
}
#[test]
fn select_with_config_default_precedence() {
let _guard = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let d = tempfile::tempdir().unwrap();
let cfg_dir = d.path().join(".rqst");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(cfg_dir.join("config"), "default_env=rqst-default\n").unwrap();
std::fs::create_dir_all(d.path().join(".rqst").join("env")).unwrap();
for name in ["explicit-env", "mnml-env", "config-default", "rqst-default"] {
std::fs::write(
d.path()
.join(".rqst")
.join("env")
.join(format!("{name}.env")),
format!("MARKER={name}\n"),
)
.unwrap();
}
let _env_guard = crate::EnvGuard::remove("MNML_ENV");
let env = EnvSet::select_with_config_default(d.path(), None, None);
assert_eq!(env.name(), Some("rqst-default"));
let env = EnvSet::select_with_config_default(d.path(), None, Some("config-default"));
assert_eq!(env.name(), Some("config-default"));
let _mnml_env = crate::EnvGuard::set("MNML_ENV", "mnml-env");
let env = EnvSet::select_with_config_default(d.path(), None, Some("config-default"));
assert_eq!(env.name(), Some("mnml-env"));
let env = EnvSet::select_with_config_default(
d.path(),
Some("explicit-env"),
Some("config-default"),
);
assert_eq!(env.name(), Some("explicit-env"));
}
}