geist_supervisor 0.1.28

Generic OTA supervisor for field devices
Documentation
use std::env;
use std::fs;
use std::path::PathBuf;

pub struct Config;

impl Config {
    // Application version from Cargo.toml
    pub const PKG_VERSION: &'static str = env!("CARGO_PKG_VERSION");

    // Supervisor data directory (version tracking, backups, etc.)
    pub fn data_dir() -> PathBuf {
        if let Ok(test_dir) = env::var("GEIST_SUPERVISOR_DATA_DIR_TEST") {
            return PathBuf::from(test_dir);
        }

        let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
        let dir = PathBuf::from(home).join(".local/share/geist-supervisor");
        if let Err(e) = fs::create_dir_all(&dir) {
            tracing::warn!("Failed to create data directory {}: {}", dir.display(), e);
        }
        dir
    }

    // Application install path (where ota_metadata.json lives)
    pub fn install_path() -> PathBuf {
        if let Ok(test_dir) = env::var("GEIST_INSTALL_PATH_TEST") {
            return PathBuf::from(test_dir);
        }
        let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
        PathBuf::from(home).join(".local/share/geist-supervisor")
    }

    /// Normalizes a version string by removing the 'v' prefix if present (for GCS paths)
    pub fn normalize_version(version: &str) -> String {
        version.trim_start_matches('v').to_string()
    }

    /// Ensures a version string has the 'v' prefix (for display and storage)
    pub fn format_version(version: &str) -> String {
        if version.starts_with('v') {
            version.to_string()
        } else {
            format!("v{}", version)
        }
    }

    /// Gets the current installed version.
    /// Priority: GEIST_CURRENT_VERSION env → ota_metadata.json → legacy current_version → package version
    pub fn get_current_version() -> String {
        // First check if it's set in environment (test override)
        if let Ok(version) = env::var("GEIST_CURRENT_VERSION") {
            return version;
        }

        Self::read_version_from_paths(&Self::install_path(), &Self::data_dir())
    }

    /// Read version from filesystem paths (testable without env vars).
    fn read_version_from_paths(
        install_path: &std::path::Path,
        data_dir: &std::path::Path,
    ) -> String {
        // Read from deployed ota_metadata.json
        let metadata_path = install_path.join("ota_metadata.json");
        if let Ok(content) = fs::read_to_string(metadata_path) {
            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
                if let Some(version) = json["version"].as_str() {
                    return Self::format_version(version);
                }
            }
        }

        // Also check legacy current_version file for migration
        let legacy_file = data_dir.join("current_version");
        if let Ok(version) = fs::read_to_string(legacy_file) {
            let v = version.trim();
            if !v.is_empty() {
                return v.to_string();
            }
        }

        // Default to package version
        format!("v{}", Self::PKG_VERSION)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_normalize_version() {
        assert_eq!(Config::normalize_version("v1.2.3"), "1.2.3");
        assert_eq!(Config::normalize_version("1.2.3"), "1.2.3");
        assert_eq!(Config::normalize_version("v0.1.0-alpha"), "0.1.0-alpha");
        assert_eq!(Config::normalize_version(""), "");
    }

    #[test]
    fn test_format_version() {
        assert_eq!(Config::format_version("1.2.3"), "v1.2.3");
        assert_eq!(Config::format_version("v1.2.3"), "v1.2.3");
        assert_eq!(Config::format_version("0.1.0-alpha"), "v0.1.0-alpha");
        assert_eq!(Config::format_version("v0.1.0-alpha"), "v0.1.0-alpha");
        assert_eq!(Config::format_version(""), "v");
    }

    #[test]
    fn test_get_version_from_metadata() {
        let temp_dir = tempdir().unwrap();
        let install_dir = temp_dir.path().join("install");
        let data_dir = temp_dir.path().join("data");
        std::fs::create_dir_all(&install_dir).unwrap();
        std::fs::create_dir_all(&data_dir).unwrap();

        // Write ota_metadata.json
        let metadata = r#"{"version": "1.2.3"}"#;
        std::fs::write(install_dir.join("ota_metadata.json"), metadata).unwrap();

        let version = Config::read_version_from_paths(&install_dir, &data_dir);
        assert_eq!(version, "v1.2.3");
    }

    #[test]
    fn test_get_version_env_override() {
        std::env::set_var("GEIST_CURRENT_VERSION", "v9.9.9");
        let version = Config::get_current_version();
        assert_eq!(version, "v9.9.9");
        std::env::remove_var("GEIST_CURRENT_VERSION");
    }

    #[test]
    fn test_get_version_fallback_pkg_version() {
        let temp_dir = tempdir().unwrap();
        let install_dir = temp_dir.path().join("install");
        let data_dir = temp_dir.path().join("data");
        std::fs::create_dir_all(&install_dir).unwrap();
        std::fs::create_dir_all(&data_dir).unwrap();
        // No ota_metadata.json, no legacy file → falls back to package version

        let version = Config::read_version_from_paths(&install_dir, &data_dir);
        assert_eq!(version, format!("v{}", Config::PKG_VERSION));
    }

    #[test]
    fn test_get_version_legacy_file() {
        let temp_dir = tempdir().unwrap();
        let install_dir = temp_dir.path().join("install");
        let data_dir = temp_dir.path().join("data");
        std::fs::create_dir_all(&install_dir).unwrap();
        std::fs::create_dir_all(&data_dir).unwrap();

        // Write legacy current_version file (no ota_metadata.json)
        std::fs::write(data_dir.join("current_version"), "v2.0.0").unwrap();

        let version = Config::read_version_from_paths(&install_dir, &data_dir);
        assert_eq!(version, "v2.0.0");
    }
}