use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const CONFIG_FILENAME: &str = "bitrouter.yaml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
File(PathBuf),
Default {
home: PathBuf,
},
}
impl ConfigSource {
pub fn home(&self) -> &Path {
match self {
Self::File(path) => path.parent().unwrap_or(Path::new(".")),
Self::Default { home } => home,
}
}
pub fn is_default(&self) -> bool {
matches!(self, Self::Default { .. })
}
}
pub fn resolve_config(explicit: Option<&Path>) -> Result<ConfigSource> {
let cwd = std::env::current_dir().ok();
let bitrouter_home = std::env::var_os("BITROUTER_HOME").filter(|v| !v.is_empty());
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
#[cfg(windows)]
let home = home.or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()));
let outcome = resolve_config_with(
explicit,
cwd.as_deref(),
bitrouter_home.as_deref().map(Path::new),
home.as_deref().map(Path::new),
)?;
Ok(match outcome {
ConfigSource::File(path) => ConfigSource::File(absolutize(path)),
ConfigSource::Default { home } => ConfigSource::Default {
home: absolutize(home),
},
})
}
fn absolutize(path: PathBuf) -> PathBuf {
if path.is_absolute() {
return path;
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => path,
}
}
pub fn resolve_config_with(
explicit: Option<&Path>,
cwd: Option<&Path>,
bitrouter_home_env: Option<&Path>,
home_env: Option<&Path>,
) -> Result<ConfigSource> {
if let Some(path) = explicit {
let p = path.to_path_buf();
if !p.exists() {
anyhow::bail!(
"config file '{}' does not exist (passed via -c). \
Drop the flag to fall back to the resolution order \
(cwd → $BITROUTER_HOME → ~/.bitrouter → zero-config).",
p.display()
);
}
return Ok(ConfigSource::File(p));
}
if let Some(cwd) = cwd {
let candidate = cwd.join(CONFIG_FILENAME);
if candidate.is_file() {
return Ok(ConfigSource::File(candidate));
}
}
if let Some(env_home) = bitrouter_home_env {
let candidate = env_home.join(CONFIG_FILENAME);
if candidate.is_file() {
return Ok(ConfigSource::File(candidate));
}
anyhow::bail!(
"BITROUTER_HOME is set to '{}' but '{}' is missing there. \
Either drop the env var or create the file (e.g. \
`bitrouter init -c $BITROUTER_HOME/{}`).",
env_home.display(),
CONFIG_FILENAME,
CONFIG_FILENAME,
);
}
let home = home_env.context(
"could not determine home directory (no $HOME set); set $BITROUTER_HOME or pass -c <path>",
)?;
let home = home.join(".bitrouter");
let candidate = home.join(CONFIG_FILENAME);
if candidate.is_file() {
return Ok(ConfigSource::File(candidate));
}
Ok(ConfigSource::Default { home })
}
pub async fn load_config(source: &ConfigSource) -> Result<bitrouter_sdk::config::Config> {
match source {
ConfigSource::File(path) => bitrouter_sdk::config::load(path)
.await
.with_context(|| format!("loading {}", path.display())),
ConfigSource::Default { .. } => {
let mut cfg = bitrouter_providers::zero_config();
crate::cloud::enable_in_zero_config(&mut cfg);
Ok(cfg)
}
}
}
pub fn ensure_home_directory(home: &Path) -> Result<()> {
std::fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(home) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = std::fs::set_permissions(home, perms);
}
}
Ok(())
}
const INSTALL_ID_FILENAME: &str = "installation.id";
pub fn get_or_create_install_id(home: &Path) -> Result<String> {
let path = home.join(INSTALL_ID_FILENAME);
if let Ok(contents) = std::fs::read_to_string(&path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
ensure_home_directory(home)?;
let id = uuid::Uuid::new_v4().to_string();
std::fs::write(&path, &id).with_context(|| format!("writing {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(id)
}
pub fn install_id() -> Result<String> {
let home = if let Some(h) = std::env::var_os("BITROUTER_HOME").filter(|v| !v.is_empty()) {
PathBuf::from(h)
} else {
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
#[cfg(windows)]
let home = home.or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()));
let home =
home.context("could not determine home directory (no $HOME set); set $BITROUTER_HOME")?;
PathBuf::from(home).join(".bitrouter")
};
get_or_create_install_id(&home)
}
const TELEMETRY_NOTICE_SENTINEL: &str = ".telemetry-notice-shown";
pub fn mark_telemetry_notice_shown(home: &Path) -> Result<bool> {
let path = home.join(TELEMETRY_NOTICE_SENTINEL);
if path.exists() {
return Ok(false);
}
ensure_home_directory(home)?;
std::fs::write(&path, "").with_context(|| format!("writing {}", path.display()))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_tmp(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bitrouter-paths-test-{label}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn install_id_is_generated_then_stable() {
let home = unique_tmp("install-id");
let first = get_or_create_install_id(&home).unwrap();
assert!(!first.is_empty());
assert_eq!(first.len(), 36);
let second = get_or_create_install_id(&home).unwrap();
assert_eq!(first, second);
let on_disk = std::fs::read_to_string(home.join(INSTALL_ID_FILENAME)).unwrap();
assert_eq!(on_disk.trim(), first);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn telemetry_notice_marked_once() {
let home = unique_tmp("tel-notice");
assert!(mark_telemetry_notice_shown(&home).unwrap());
assert!(!mark_telemetry_notice_shown(&home).unwrap());
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn install_id_rewrites_empty_file() {
let home = unique_tmp("install-id-empty");
std::fs::write(home.join(INSTALL_ID_FILENAME), " \n").unwrap();
let id = get_or_create_install_id(&home).unwrap();
assert_eq!(id.len(), 36);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn explicit_path_is_used_verbatim_when_it_exists() {
let dir = unique_tmp("explicit-ok");
let path = dir.join("bitrouter.yaml");
std::fs::write(&path, "server: {listen: '127.0.0.1:0'}").unwrap();
let resolved = resolve_config_with(Some(&path), None, None, None).unwrap();
assert_eq!(resolved, ConfigSource::File(path));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn explicit_missing_path_errors_clearly() {
let dir = unique_tmp("explicit-missing");
let path = dir.join("nope.yaml");
let err = resolve_config_with(Some(&path), None, None, None).unwrap_err();
assert!(err.to_string().contains("does not exist"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cwd_bitrouter_yaml_wins_over_env_and_home() {
let cwd = unique_tmp("cwd-hit");
let path = cwd.join("bitrouter.yaml");
std::fs::write(&path, "server: {listen: '127.0.0.1:0'}").unwrap();
let env_home = unique_tmp("env-distractor");
let home_env = unique_tmp("home-distractor");
let resolved =
resolve_config_with(None, Some(&cwd), Some(&env_home), Some(&home_env)).unwrap();
assert_eq!(resolved, ConfigSource::File(path));
let _ = std::fs::remove_dir_all(&cwd);
let _ = std::fs::remove_dir_all(&env_home);
let _ = std::fs::remove_dir_all(&home_env);
}
#[test]
fn bitrouter_home_env_resolves_when_file_exists() {
let env_home = unique_tmp("env-hit");
let path = env_home.join("bitrouter.yaml");
std::fs::write(&path, "server: {listen: '127.0.0.1:0'}").unwrap();
let cwd = unique_tmp("env-parking");
let resolved = resolve_config_with(None, Some(&cwd), Some(&env_home), None).unwrap();
assert_eq!(resolved, ConfigSource::File(path));
let _ = std::fs::remove_dir_all(&env_home);
let _ = std::fs::remove_dir_all(&cwd);
}
#[test]
fn bitrouter_home_set_but_file_missing_errors_with_hint() {
let env_home = unique_tmp("env-empty");
let cwd = unique_tmp("env-empty-parking");
let err = resolve_config_with(None, Some(&cwd), Some(&env_home), None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("BITROUTER_HOME") && msg.contains("missing"),
"error should hint at BITROUTER_HOME: {msg}"
);
let _ = std::fs::remove_dir_all(&env_home);
let _ = std::fs::remove_dir_all(&cwd);
}
#[test]
fn falls_back_to_zero_config_when_nothing_else_matches() {
let home_root = unique_tmp("home-fallback");
let resolved = resolve_config_with(None, None, None, Some(&home_root)).unwrap();
match resolved {
ConfigSource::Default { home } => {
assert_eq!(home, home_root.join(".bitrouter"));
}
other => panic!("expected ConfigSource::Default, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&home_root);
}
#[test]
fn dot_bitrouter_existing_config_is_used_directly() {
let home_root = unique_tmp("home-existing");
let dot = home_root.join(".bitrouter");
std::fs::create_dir_all(&dot).unwrap();
let path = dot.join("bitrouter.yaml");
std::fs::write(&path, "server: {listen: '127.0.0.1:0'}").unwrap();
let resolved = resolve_config_with(None, None, None, Some(&home_root)).unwrap();
assert_eq!(resolved, ConfigSource::File(path));
let _ = std::fs::remove_dir_all(&home_root);
}
#[test]
fn config_source_default_reports_its_home() {
let source = ConfigSource::Default {
home: PathBuf::from("/tmp/x"),
};
assert_eq!(source.home(), Path::new("/tmp/x"));
assert!(source.is_default());
}
#[test]
fn config_source_file_reports_parent_as_home() {
let source = ConfigSource::File(PathBuf::from("/tmp/x/bitrouter.yaml"));
assert_eq!(source.home(), Path::new("/tmp/x"));
assert!(!source.is_default());
}
#[test]
fn no_home_and_no_env_errors_with_helpful_message() {
let err = resolve_config_with(None, None, None, None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("BITROUTER_HOME") || msg.contains("HOME"),
"error should mention how to recover: {msg}"
);
}
}