xbp 10.12.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! configuration management module
//!
//! handles ssh configuration and yaml config file management
//! provides loading and saving of configuration files
//! supports home directory based config storage

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,
}

#[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 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(),
        }
    }
}

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"),
        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)?;

    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),
    ])
}

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(&registry_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)
}

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 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(),
    ]
}

const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";

/// Simple API configuration for the XBP version endpoints.
#[derive(Debug, Clone)]
pub struct ApiConfig {
    base_url: String,
}

impl ApiConfig {
    /// Load the API configuration from API_XBP_URL, falling back to the default.
    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 }
    }

    /// Return the normalized base URL that downstream callers should use.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    /// Build the version query endpoint.
    pub fn version_endpoint(&self, project_name: &str) -> String {
        format!("{}/version?project_name={}", self.base_url, project_name)
    }

    /// Build the endpoint that increments a version.
    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_versioning_files, sync_versioning_files_registry_at, ApiConfig,
        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");
    }
}