use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use regex::Regex;
use tracing::debug;
use super::schema::Config;
pub fn path_to_forward_slash(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
static ENV_VAR_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("valid regex")
});
pub fn expand_env_vars(raw: &str) -> String {
let expanded = ENV_VAR_RE
.replace_all(raw, |caps: ®ex::Captures<'_>| {
let var = &caps[1];
std::env::var(var).unwrap_or_else(|_| {
debug!(var, "env var not set (referenced in config)");
caps[0].to_string()
})
})
.into_owned();
if let Some(home) = dirs_next::home_dir() {
let home_s = path_to_forward_slash(&home);
expanded.replace("~/", &format!("{home_s}/"))
} else {
expanded
}
}
pub fn load_json5(path: &Path) -> Result<Config> {
let base_dir = path.parent().unwrap_or(Path::new("."));
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config: {}", path.display()))?;
let expanded = expand_env_vars(&raw);
let mut value: serde_json::Value = json5::from_str(&expanded)
.with_context(|| format!("JSON5 parse error in {}", path.display()))?;
resolve_includes(&mut value, base_dir, 0)?;
let config: Config = serde_json::from_value(value)
.with_context(|| format!("schema error in {}", path.display()))?;
Ok(config)
}
const MAX_INCLUDE_DEPTH: usize = 10;
fn resolve_includes(value: &mut serde_json::Value, base_dir: &Path, depth: usize) -> Result<()> {
if depth > MAX_INCLUDE_DEPTH {
anyhow::bail!("$include nesting exceeds maximum depth of {MAX_INCLUDE_DEPTH}");
}
match value {
serde_json::Value::Object(map) => {
let include_keys: Vec<String> = map
.iter()
.filter(|(_, v)| has_include(v))
.map(|(k, _)| k.clone())
.collect();
for key in include_keys {
let path_str = extract_include_path(&map[&key])
.with_context(|| format!("$include in key `{key}`"))?;
let include_path = if let Some(rest) = path_str.strip_prefix("~/") {
dirs_next::home_dir().unwrap_or_default().join(rest)
} else {
base_dir.join(&path_str)
};
let included = load_include_file(&include_path, depth + 1)?;
map.insert(key, included);
}
for v in map.values_mut() {
resolve_includes(v, base_dir, depth)?;
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
resolve_includes(v, base_dir, depth)?;
}
}
_ => {}
}
Ok(())
}
fn has_include(value: &serde_json::Value) -> bool {
matches!(value, serde_json::Value::Object(m) if m.contains_key("$include") && m.len() == 1)
}
fn extract_include_path(value: &serde_json::Value) -> Result<String> {
let map = value.as_object().expect("caller checked");
map["$include"]
.as_str()
.map(str::to_owned)
.with_context(|| "$include value must be a string path")
}
fn load_include_file(path: &Path, depth: usize) -> Result<serde_json::Value> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read $include: {}", path.display()))?;
let expanded = expand_env_vars(&raw);
let mut value: serde_json::Value = json5::from_str(&expanded)
.with_context(|| format!("JSON5 parse error in $include {}", path.display()))?;
let base_dir = path.parent().unwrap_or(Path::new("."));
resolve_includes(&mut value, base_dir, depth)?;
Ok(value)
}
pub fn detect_config_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("RSCLAW_CONFIG_PATH") {
let path = expand_tilde_path(&p);
if path.exists() {
return Some(path);
}
}
if let Ok(bd) = std::env::var("RSCLAW_BASE_DIR") {
let p = expand_tilde_path(&bd).join("rsclaw.json5");
if p.exists() {
return Some(p);
}
}
let home = dirs_next::home_dir()?;
let rsclaw = home.join(".rsclaw/rsclaw.json5");
if rsclaw.exists() {
return Some(rsclaw);
}
let local = PathBuf::from(".rsclaw.json5");
if local.exists() {
return Some(local);
}
None
}
pub fn base_dir() -> PathBuf {
if let Ok(p) = std::env::var("RSCLAW_BASE_DIR") {
return expand_tilde_path(&p);
}
if let Some(config_path) = detect_config_path() {
if let Some(parent) = config_path.parent() {
return parent.to_path_buf();
}
}
dirs_next::home_dir().unwrap_or_default().join(".rsclaw")
}
pub fn pid_file() -> PathBuf {
base_dir().join("var/run/gateway.pid")
}
pub fn log_file() -> PathBuf {
base_dir().join("var/logs/gateway.log")
}
pub fn cache_dir() -> PathBuf {
base_dir().join("var/cache")
}
pub fn load_defaults_toml() -> String {
let external = base_dir().join("defaults.toml");
if let Ok(content) = std::fs::read_to_string(&external) {
debug!(path = %external.display(), "loaded external defaults.toml");
content
} else {
include_str!("../../defaults.toml").to_owned()
}
}
pub fn expand_tilde_path_pub(p: &str) -> PathBuf {
expand_tilde_path(p)
}
fn expand_tilde_path(p: &str) -> PathBuf {
if let Some(rest) = p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
dirs_next::home_dir().unwrap_or_default().join(rest)
} else if p == "~" {
dirs_next::home_dir().unwrap_or_default()
} else {
PathBuf::from(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_known_var() {
unsafe { std::env::set_var("TEST_API_KEY_RSCLAW", "sk-test-123") };
let result = expand_env_vars(r#"{"apiKey": "${TEST_API_KEY_RSCLAW}"}"#);
assert!(result.contains("sk-test-123"), "got: {result}");
}
#[test]
fn expand_missing_var_leaves_verbatim() {
let input = r#"{"apiKey": "${RSCLAW_NONEXISTENT_XYZ}"}"#;
let result = expand_env_vars(input);
assert!(
result.contains("${RSCLAW_NONEXISTENT_XYZ}"),
"got: {result}"
);
}
#[test]
fn include_directive_loads_nested_file() {
let dir = tempfile::tempdir().unwrap();
let sub_path = dir.path().join("agents.json5");
std::fs::write(&sub_path, r#"{ list: [{ id: "main", default: true }] }"#).unwrap();
let main_path = dir.path().join("openclaw.json5");
std::fs::write(
&main_path,
r#"{ agents: { "$include": "./agents.json5" } }"#,
)
.unwrap();
let cfg = load_json5(&main_path).unwrap();
let agents = cfg.agents.expect("agents should be present");
let list = agents.list.expect("agents.list should be present");
assert_eq!(list[0].id, "main");
}
}