use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct GlobalXbpPaths {
pub root_dir: PathBuf,
pub config_file: PathBuf,
pub ssh_dir: PathBuf,
pub cache_dir: PathBuf,
pub logs_dir: PathBuf,
pub versioning_files_file: PathBuf,
pub package_name_files_file: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SshConfig {
pub password: Option<String>,
pub username: Option<String>,
pub host: Option<String>,
pub project_dir: Option<String>,
}
impl Default for SshConfig {
fn default() -> Self {
Self::new()
}
}
impl SshConfig {
pub fn new() -> Self {
SshConfig {
password: None,
username: None,
host: None,
project_dir: None,
}
}
pub fn load() -> Result<Self, String> {
let config_path = get_config_path();
let legacy_path = legacy_config_path();
let path_to_read = if config_path.exists() {
config_path
} else if legacy_path.exists() {
legacy_path
} else {
return Ok(SshConfig::new());
};
let content = fs::read_to_string(&path_to_read)
.map_err(|e| format!("Failed to read config file: {}", e))?;
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
}
pub fn save(&self) -> Result<(), String> {
let config_path = get_config_path();
let config_dir = config_path.parent().ok_or("Invalid config path")?;
fs::create_dir_all(config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
let content = serde_yaml::to_string(self)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersioningFilesConfig {
#[serde(default = "default_versioning_files")]
pub files: Vec<String>,
}
impl Default for VersioningFilesConfig {
fn default() -> Self {
Self {
files: default_versioning_files(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct PackageNameLookup {
pub file: String,
pub format: String,
pub key: String,
pub registry: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PackageNameFilesConfig {
#[serde(default = "default_package_name_lookups")]
pub lookups: Vec<PackageNameLookup>,
}
impl Default for PackageNameFilesConfig {
fn default() -> Self {
Self {
lookups: default_package_name_lookups(),
}
}
}
pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
let root_dir = dirs::config_dir()
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("xbp");
let paths = GlobalXbpPaths {
config_file: root_dir.join("config.yaml"),
ssh_dir: root_dir.join("ssh"),
cache_dir: root_dir.join("cache"),
logs_dir: root_dir.join("logs"),
versioning_files_file: root_dir.join("versioning-files.yaml"),
package_name_files_file: root_dir.join("package-name-files.yaml"),
root_dir,
};
for dir in [
&paths.root_dir,
&paths.ssh_dir,
&paths.cache_dir,
&paths.logs_dir,
] {
fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
}
if !paths.config_file.exists() {
fs::write(
&paths.config_file,
"password: null\nusername: null\nhost: null\nproject_dir: null\n",
)
.map_err(|e| {
format!(
"Failed to initialize config file {}: {}",
paths.config_file.display(),
e
)
})?;
}
sync_versioning_files_registry_at(&paths.versioning_files_file)?;
sync_package_name_files_registry_at(&paths.package_name_files_file)?;
Ok(paths)
}
pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
ensure_global_xbp_paths()
}
pub fn get_config_path() -> PathBuf {
ensure_global_xbp_paths()
.map(|paths| paths.config_file)
.unwrap_or_else(|_| legacy_config_path())
}
fn legacy_config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".xbp")
.join("config.yaml")
}
pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
let paths = global_xbp_paths()?;
Ok(vec![
("root".to_string(), paths.root_dir),
("config".to_string(), paths.config_file),
("ssh".to_string(), paths.ssh_dir),
("cache".to_string(), paths.cache_dir),
("logs".to_string(), paths.logs_dir),
("versioning".to_string(), paths.versioning_files_file),
("package-names".to_string(), paths.package_name_files_file),
])
}
pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths.versioning_files_file)
}
pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
let registry_path = sync_versioning_files_registry()?;
let content = fs::read_to_string(®istry_path).map_err(|e| {
format!(
"Failed to read versioning registry {}: {}",
registry_path.display(),
e
)
})?;
let config: VersioningFilesConfig = serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
Ok(config.files)
}
pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths.package_name_files_file)
}
pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
let registry_path = sync_package_name_files_registry()?;
let content = fs::read_to_string(®istry_path).map_err(|e| {
format!(
"Failed to read package-name registry {}: {}",
registry_path.display(),
e
)
})?;
let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
Ok(config.lookups)
}
fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
let mut config = if path.exists() {
let content = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read versioning registry {}: {}",
path.display(),
e
)
})?;
serde_yaml::from_str::<VersioningFilesConfig>(&content)
.unwrap_or_else(|_| VersioningFilesConfig::default())
} else {
VersioningFilesConfig::default()
};
let mut changed = false;
for default_file in default_versioning_files() {
if !config
.files
.iter()
.any(|existing| existing == &default_file)
{
config.files.push(default_file);
changed = true;
}
}
if changed || !path.exists() {
let content = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
fs::write(path, content).map_err(|e| {
format!(
"Failed to write versioning registry {}: {}",
path.display(),
e
)
})?;
}
Ok(())
}
fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
let mut config = if path.exists() {
let content = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read package-name registry {}: {}",
path.display(),
e
)
})?;
serde_yaml::from_str::<PackageNameFilesConfig>(&content)
.unwrap_or_else(|_| PackageNameFilesConfig::default())
} else {
PackageNameFilesConfig::default()
};
let mut changed = false;
for default_lookup in default_package_name_lookups() {
if !config
.lookups
.iter()
.any(|existing| existing == &default_lookup)
{
config.lookups.push(default_lookup);
changed = true;
}
}
if changed || !path.exists() {
let content = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
fs::write(path, content).map_err(|e| {
format!(
"Failed to write package-name registry {}: {}",
path.display(),
e
)
})?;
}
Ok(())
}
fn default_versioning_files() -> Vec<String> {
vec![
"README.md".to_string(),
"openapi.yaml".to_string(),
"openapi.yml".to_string(),
"package.json".to_string(),
"package-lock.json".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"pyproject.toml".to_string(),
"composer.json".to_string(),
"deno.json".to_string(),
"deno.jsonc".to_string(),
"Chart.yaml".to_string(),
"app.json".to_string(),
"manifest.json".to_string(),
"pom.xml".to_string(),
"build.gradle".to_string(),
"build.gradle.kts".to_string(),
"mix.exs".to_string(),
"xbp.yaml".to_string(),
"xbp.yml".to_string(),
"xbp.json".to_string(),
".xbp/xbp.json".to_string(),
".xbp/xbp.yaml".to_string(),
".xbp/xbp.yml".to_string(),
]
}
fn default_package_name_lookups() -> Vec<PackageNameLookup> {
vec![
PackageNameLookup {
file: "package.json".to_string(),
format: "json".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
},
PackageNameLookup {
file: "Cargo.toml".to_string(),
format: "toml".to_string(),
key: "package.name".to_string(),
registry: "crates.io".to_string(),
},
]
}
const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
#[derive(Debug, Clone)]
pub struct ApiConfig {
base_url: String,
}
impl ApiConfig {
pub fn load() -> Self {
let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
let base_url = Self::normalize_base_url(&raw_url);
ApiConfig { base_url }
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn version_endpoint(&self, project_name: &str) -> String {
format!("{}/version?project_name={}", self.base_url, project_name)
}
pub fn increment_endpoint(&self) -> String {
format!("{}/version/increment", self.base_url)
}
fn normalize_base_url(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return DEFAULT_API_XBP_URL.to_string();
}
let trimmed = trimmed.trim_end_matches('/');
if trimmed.is_empty() {
return DEFAULT_API_XBP_URL.to_string();
}
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
default_package_name_lookups, default_versioning_files,
sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
PackageNameFilesConfig, VersioningFilesConfig,
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
}
#[test]
fn versioning_registry_defaults_include_core_files() {
let defaults = default_versioning_files();
assert!(defaults.contains(&"README.md".to_string()));
assert!(defaults.contains(&"Cargo.toml".to_string()));
assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
}
#[test]
fn versioning_registry_default_config_populates_files() {
let config = VersioningFilesConfig::default();
assert!(!config.files.is_empty());
}
#[test]
fn versioning_registry_defaults_do_not_contain_duplicates() {
let defaults = default_versioning_files();
let mut deduped = defaults.clone();
deduped.sort();
deduped.dedup();
assert_eq!(defaults.len(), deduped.len());
}
#[test]
fn syncing_registry_creates_file_with_defaults() {
let path = temp_path("defaults");
sync_versioning_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("README.md"));
assert!(content.contains("Cargo.toml"));
let _ = fs::remove_file(path);
}
#[test]
fn syncing_registry_preserves_user_added_entries() {
let path = temp_path("preserve");
fs::write(&path, "files:\n - custom.file\n").expect("write registry");
sync_versioning_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("custom.file"));
assert!(content.contains("README.md"));
let _ = fs::remove_file(path);
}
#[test]
fn api_config_normalizes_trailing_slashes() {
assert_eq!(
ApiConfig::normalize_base_url("https://api.xbp.app///"),
"https://api.xbp.app".to_string()
);
}
#[test]
fn api_config_uses_default_for_blank_values() {
assert_eq!(
ApiConfig::normalize_base_url(" "),
"https://api.xbp.app".to_string()
);
}
#[test]
fn api_config_builds_version_endpoints() {
let config = ApiConfig {
base_url: "https://api.test.xbp".to_string(),
};
let endpoint = config.version_endpoint("demo");
let increment = config.increment_endpoint();
assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
assert_eq!(increment, "https://api.test.xbp/version/increment");
}
#[test]
fn package_lookup_defaults_include_npm_and_crates() {
let defaults = default_package_name_lookups();
assert!(defaults.iter().any(|entry| {
entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
}));
assert!(defaults.iter().any(|entry| {
entry.file == "Cargo.toml"
&& entry.registry == "crates.io"
&& entry.key == "package.name"
}));
}
#[test]
fn package_lookup_default_config_populates_entries() {
let config = PackageNameFilesConfig::default();
assert!(!config.lookups.is_empty());
}
#[test]
fn syncing_package_lookup_registry_creates_defaults() {
let path = temp_path("package-lookup-defaults");
sync_package_name_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("package.json"));
assert!(content.contains("Cargo.toml"));
let _ = fs::remove_file(path);
}
#[test]
fn syncing_package_lookup_registry_preserves_custom_entries() {
let path = temp_path("package-lookup-custom");
fs::write(
&path,
"lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
)
.expect("write package lookup registry");
sync_package_name_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("custom.yaml"));
assert!(content.contains("package.json"));
let _ = fs::remove_file(path);
}
}