use anyhow::{Context, Result};
use directories::ProjectDirs;
use std::env;
use std::path::{Path, PathBuf};
use tokio::fs;
use super::types::{Cluster, ClusterDefaults, Config, NodeConfig};
use super::utils::{expand_tilde, get_current_username};
impl Config {
pub async fn load(path: &Path) -> Result<Self> {
let expanded_path = expand_tilde(path);
if !expanded_path.exists() {
tracing::debug!(
"Config file not found at {:?}, using defaults",
expanded_path
);
return Ok(Self::default());
}
let content = fs::read_to_string(&expanded_path)
.await
.with_context(|| format!("Failed to read configuration file at {}. Please check file permissions and ensure the file is accessible.", expanded_path.display()))?;
let config: Config =
serde_yaml::from_str(&content).with_context(|| format!("Failed to parse YAML configuration file at {}. Please check the YAML syntax is valid.\nCommon issues:\n - Incorrect indentation (use spaces, not tabs)\n - Missing colons after keys\n - Unquoted special characters", expanded_path.display()))?;
Ok(config)
}
pub fn from_backendai_env() -> Option<Cluster> {
let cluster_hosts = env::var("BACKENDAI_CLUSTER_HOSTS").ok()?;
let _current_host = env::var("BACKENDAI_CLUSTER_HOST").ok()?;
let cluster_role = env::var("BACKENDAI_CLUSTER_ROLE").ok();
let mut nodes = Vec::new();
for host in cluster_hosts.split(',') {
let host = host.trim();
if !host.is_empty() {
let default_user = get_current_username();
nodes.push(NodeConfig::Simple(format!("{default_user}@{host}:2200")));
}
}
if nodes.is_empty() {
return None;
}
let filtered_nodes = if let Some(role) = &cluster_role {
if role == "main" {
nodes
} else {
nodes.into_iter().skip(1).collect()
}
} else {
nodes
};
Some(Cluster {
nodes: filtered_nodes,
defaults: ClusterDefaults {
ssh_key: Some("/home/config/ssh/id_cluster".to_string()),
..ClusterDefaults::default()
},
interactive: None,
})
}
pub async fn load_with_priority(cli_config_path: &Path) -> Result<Self> {
let default_config_path = PathBuf::from("~/.config/bssh/config.yaml");
let expanded_cli_path = expand_tilde(cli_config_path);
let expanded_default_path = expand_tilde(&default_config_path);
let is_custom_config = expanded_cli_path != expanded_default_path;
if is_custom_config && expanded_cli_path.exists() {
tracing::debug!(
"Using explicitly specified config file: {:?}",
expanded_cli_path
);
return Self::load(&expanded_cli_path).await;
} else if is_custom_config {
tracing::debug!(
"Custom config file not found, continuing with other sources: {:?}",
expanded_cli_path
);
}
if let Some(backendai_cluster) = Self::from_backendai_env() {
tracing::debug!("Using Backend.AI cluster configuration from environment");
let mut config = Self::default();
config
.clusters
.insert("bai_auto".to_string(), backendai_cluster);
return Ok(config);
}
Self::load_from_standard_locations().await.or_else(|_| {
tracing::debug!("No config file found, using default empty configuration");
Ok(Self::default())
})
}
async fn load_from_standard_locations() -> Result<Self> {
let current_dir_config = PathBuf::from("config.yaml");
if current_dir_config.exists() {
tracing::debug!("Found config.yaml in current directory");
if let Ok(config) = Self::load(¤t_dir_config).await {
return Ok(config);
}
}
if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
let xdg_config = PathBuf::from(xdg_config_home)
.join("bssh")
.join("config.yaml");
tracing::debug!("Checking XDG_CONFIG_HOME path: {:?}", xdg_config);
if xdg_config.exists() {
tracing::debug!("Found config at XDG_CONFIG_HOME: {:?}", xdg_config);
if let Ok(config) = Self::load(&xdg_config).await {
return Ok(config);
}
}
} else {
if let Ok(home) = env::var("HOME") {
let xdg_config = PathBuf::from(home)
.join(".config")
.join("bssh")
.join("config.yaml");
tracing::debug!("Checking ~/.config/bssh path: {:?}", xdg_config);
if xdg_config.exists() {
tracing::debug!("Found config at ~/.config/bssh: {:?}", xdg_config);
if let Ok(config) = Self::load(&xdg_config).await {
return Ok(config);
}
}
}
}
anyhow::bail!("No configuration file found")
}
pub async fn save(&self, path: &Path) -> Result<()> {
let expanded_path = expand_tilde(path);
if let Some(parent) = expanded_path.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create directory {parent:?}"))?;
}
let yaml =
serde_yaml::to_string(self).context("Failed to serialize configuration to YAML")?;
fs::write(&expanded_path, yaml)
.await
.with_context(|| format!("Failed to write configuration to {expanded_path:?}"))?;
Ok(())
}
pub(crate) fn get_config_path(&self) -> Result<PathBuf> {
let current_dir_config = PathBuf::from("config.yaml");
if current_dir_config.exists() {
return Ok(current_dir_config);
}
if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
let xdg_config = PathBuf::from(xdg_config_home)
.join("bssh")
.join("config.yaml");
return Ok(xdg_config);
} else if let Some(proj_dirs) = ProjectDirs::from("", "", "bssh") {
let xdg_config = proj_dirs.config_dir().join("config.yaml");
return Ok(xdg_config);
}
let home = env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
.context("Unable to determine home directory")?;
Ok(PathBuf::from(home).join(".bssh").join("config.yaml"))
}
}