//! Environments (`http-client.env.json`, `http-client.private.env.json`) and //! `{{variable}}` substitution. //! //! ```json //! { "$shared": { "version": "v1" }, "dev": { "host": "localhost:8080" } } //! ``` //! //! Lookup order for `{{name}}`: file variables (`@name = …`), the private //! environment, the public environment, private `$shared`, public `$shared`. //! Dynamic variables: `$uuid` / `$random.uuid`, `$timestamp`, `$isoTimestamp`, //! `$randomInt` / `$random.integer`. use ide_core::{IdeError, IdeResult}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; pub const PUBLIC_FILE: &str = "http-client.env.json"; pub const PRIVATE_FILE: &str = "http-client.private.env.json"; const SHARED: &str = "$shared"; type EnvMap = BTreeMap>; /// The environment files that apply to a request file. #[derive(Debug, Default, Clone)] pub struct Environments { public: EnvMap, private: EnvMap, } /// Folders searched for env files: the request file's folder and its /// ancestors up to (and including) `root`; `root` itself when the file is outside it. fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec { let mut dirs = Vec::new(); if let Some(mut dir) = file.and_then(Path::parent) { loop { dirs.push(dir.to_path_buf()); if root.is_none_or(|r| dir == r || !dir.starts_with(r)) { break; } match dir.parent() { Some(p) => dir = p, None => break, } } } if let Some(r) = root && !dirs.iter().any(|d| d == r) { dirs.push(r.to_path_buf()); } dirs } fn parse_env(text: &str) -> IdeResult { let value: serde_json::Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?; let mut out = EnvMap::new(); let Some(envs) = value.as_object() else { return Ok(out) }; for (env, vars) in envs { let Some(vars) = vars.as_object() else { continue }; let mut map = BTreeMap::new(); for (k, v) in vars { let s = match v { serde_json::Value::String(s) => s.clone(), serde_json::Value::Number(n) => n.to_string(), serde_json::Value::Bool(b) => b.to_string(), // Objects configure SSL / auth in IDEA; not variables. _ => continue, }; map.insert(k.clone(), s); } out.insert(env.clone(), map); } Ok(out) } fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult { for d in dirs { let p = d.join(name); if let Ok(text) = std::fs::read_to_string(&p) { return parse_env(&text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message))); } } Ok(EnvMap::new()) } impl Environments { pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult { let dirs = search_dirs(file, root); Ok(Self { public: read_first(&dirs, PUBLIC_FILE)?, private: read_first(&dirs, PRIVATE_FILE)? }) } pub fn from_json(public: &str, private: &str) -> IdeResult { let p = |s: &str| if s.trim().is_empty() { Ok(EnvMap::new()) } else { parse_env(s) }; Ok(Self { public: p(public)?, private: p(private)? }) } /// Environment names (excluding `$shared`), sorted. pub fn names(&self) -> Vec { let set: BTreeSet<&String> = self.public.keys().chain(self.private.keys()).filter(|k| *k != SHARED).collect(); set.into_iter().cloned().collect() } /// Variables of `env` merged in lookup order (first wins). pub fn variables(&self, env: Option<&str>) -> HashMap { let mut out = HashMap::new(); let mut layer = |m: Option<&BTreeMap>| { for (k, v) in m.into_iter().flatten() { out.entry(k.clone()).or_insert_with(|| v.clone()); } }; if let Some(env) = env { layer(self.private.get(env)); layer(self.public.get(env)); } layer(self.private.get(SHARED)); layer(self.public.get(SHARED)); out } } fn dynamic(name: &str) -> Option { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); match name { "$uuid" | "$random.uuid" => Some(uuid::Uuid::new_v4().to_string()), "$timestamp" => Some(now.as_secs().to_string()), "$isoTimestamp" => Some(iso8601(now.as_secs())), "$randomInt" | "$random.integer" | "$random.integer()" => { Some((uuid::Uuid::new_v4().as_u128() % 1000).to_string()) } _ => None, } } /// `secs` since the epoch as `YYYY-MM-DDTHH:MM:SSZ` (UTC). fn iso8601(secs: u64) -> String { let days = (secs / 86_400) as i64; let rem = secs % 86_400; // Civil-from-days (Howard Hinnant). let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; 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 = yoe + era * 400 + i64::from(m <= 2); format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) } /// Resolves `{{name}}` references. File variables may reference other /// variables; unresolved names are collected in `missing` and left as written. pub struct Resolver<'a> { pub file_vars: &'a [(String, String)], pub env: HashMap, } impl Resolver<'_> { fn lookup(&self, name: &str, depth: u32, missing: &mut BTreeSet) -> Option { if let Some((_, v)) = self.file_vars.iter().rev().find(|(k, _)| k == name) { return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() }); } if let Some(v) = self.env.get(name) { return Some(if depth < 8 { self.substitute_inner(v, depth + 1, missing) } else { v.clone() }); } dynamic(name) } fn substitute_inner(&self, text: &str, depth: u32, missing: &mut BTreeSet) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(start) = rest.find("{{") { out.push_str(&rest[..start]); let after = &rest[start + 2..]; let Some(end) = after.find("}}") else { out.push_str(&rest[start..]); return out; }; let name = after[..end].trim(); match self.lookup(name, depth, missing) { Some(v) => out.push_str(&v), None => { missing.insert(name.to_owned()); out.push_str(&rest[start..start + 2 + end + 2]); } } rest = &after[end + 2..]; } out.push_str(rest); out } pub fn substitute(&self, text: &str, missing: &mut BTreeSet) -> String { self.substitute_inner(text, 0, missing) } } #[cfg(test)] mod tests { use super::*; #[test] fn merges_private_public_and_shared() { let envs = Environments::from_json( r#"{"$shared":{"v":"1","host":"shared"},"dev":{"host":"localhost","port":8080,"ssl":{"x":1}},"prod":{}}"#, r#"{"dev":{"token":"secret"},"$shared":{"v":"2"}}"#, ) .unwrap(); assert_eq!(envs.names(), vec!["dev", "prod"]); let vars = envs.variables(Some("dev")); assert_eq!(vars["host"], "localhost"); assert_eq!(vars["port"], "8080"); assert_eq!(vars["token"], "secret"); assert_eq!(vars["v"], "2"); assert!(!vars.contains_key("ssl")); assert_eq!(envs.variables(None)["host"], "shared"); } #[test] fn substitutes_file_env_and_dynamic_variables() { let file_vars = vec![("base".to_owned(), "https://{{host}}".to_owned())]; let env = HashMap::from([("host".to_owned(), "example.com".to_owned())]); let r = Resolver { file_vars: &file_vars, env }; let mut missing = BTreeSet::new(); assert_eq!(r.substitute("{{ base }}/a?x={{nope}}", &mut missing), "https://example.com/a?x={{nope}}"); assert_eq!(missing.into_iter().collect::>(), vec!["nope"]); let mut missing = BTreeSet::new(); let id = r.substitute("{{$uuid}}", &mut missing); assert_eq!(id.len(), 36); assert!(missing.is_empty()); assert_eq!(r.substitute("{{unterminated", &mut missing), "{{unterminated"); } #[test] fn self_reference_terminates() { let file_vars = vec![("a".to_owned(), "{{a}}".to_owned())]; let r = Resolver { file_vars: &file_vars, env: HashMap::new() }; let mut missing = BTreeSet::new(); assert_eq!(r.substitute("{{a}}", &mut missing), "{{a}}"); } #[test] fn iso_timestamps() { assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z"); } #[test] fn finds_env_files_in_ancestors_up_to_root() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::create_dir_all(root.join("api/v1")).unwrap(); std::fs::write(root.join(PUBLIC_FILE), r#"{"dev":{"host":"root"}}"#).unwrap(); std::fs::write(root.join("api").join(PRIVATE_FILE), r#"{"dev":{"token":"t"}}"#).unwrap(); let envs = Environments::load(Some(&root.join("api/v1/a.http")), Some(root)).unwrap(); let vars = envs.variables(Some("dev")); assert_eq!(vars["host"], "root"); assert_eq!(vars["token"], "t"); // A scratch file outside the project still sees the project's environments. let other = tempfile::tempdir().unwrap(); let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap(); assert_eq!(envs.names(), vec!["dev"]); } }