switchdev 0.1.0

A fast CLI to instantly switch between development projects and run their startup commands
use crate::error::SwitchdevError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Serialize, Deserialize)]
pub struct Project {
    pub name: String,
    pub path: String,
    pub commands: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub projects: Vec<Project>,
}

fn get_config_path_from(base: &Path) -> PathBuf {
    base.join(".switchdev").join("config.json")
}

pub fn get_config_path() -> Result<PathBuf, SwitchdevError> {
    let home_dir = dirs::home_dir().ok_or(SwitchdevError::ConfigLoadFailed)?;

    Ok(get_config_path_from(&home_dir))
}

pub fn load_config() -> Result<Config, SwitchdevError> {
    let config_path = get_config_path()?;
    load_config_at(&config_path)
}

fn load_config_at(config_path: &Path) -> Result<Config, SwitchdevError> {
    if !config_path.exists() {
        let config_dir = config_path
            .parent()
            .ok_or(SwitchdevError::ConfigLoadFailed)?;

        fs::create_dir_all(config_dir).map_err(|_| SwitchdevError::ConfigLoadFailed)?;

        let empty_config = empty_config();
        save_config_at(config_path, &empty_config)?;
    }

    let contents = fs::read_to_string(config_path).map_err(|_| SwitchdevError::ConfigLoadFailed)?;

    match serde_json::from_str(&contents) {
        Ok(config) => Ok(config),
        Err(_) => {
            println!("[✗] Config file is corrupted");

            let backup_path = config_path.with_extension("json.bak");

            if fs::rename(config_path, &backup_path).is_ok() {
                println!("[i] Backup created at {}", backup_path.to_string_lossy());
            }

            let clean_config = empty_config();
            save_config_at(config_path, &clean_config)?;
            println!("[i] New config initialized");

            Ok(clean_config)
        }
    }
}

pub fn save_config(config: &Config) -> Result<(), SwitchdevError> {
    let config_path = get_config_path()?;
    save_config_at(&config_path, config)
}

fn save_config_at(config_path: &Path, config: &Config) -> Result<(), SwitchdevError> {
    let config_dir = config_path
        .parent()
        .ok_or(SwitchdevError::ConfigSaveFailed)?;

    fs::create_dir_all(config_dir).map_err(|_| SwitchdevError::ConfigSaveFailed)?;

    let serialized =
        serde_json::to_string_pretty(config).map_err(|_| SwitchdevError::ConfigSaveFailed)?;
    let temp_path = config_path.with_extension("json.tmp");

    fs::write(&temp_path, serialized).map_err(|_| SwitchdevError::ConfigSaveFailed)?;
    fs::rename(&temp_path, config_path).map_err(|_| SwitchdevError::ConfigSaveFailed)?;

    Ok(())
}

fn empty_config() -> Config {
    Config {
        projects: Vec::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn test_base_dir(test_name: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();

        std::env::temp_dir().join(format!("switchdev-{test_name}-{unique}"))
    }

    #[test]
    fn load_config_creates_file_if_missing() {
        let base_dir = test_base_dir("load-config");
        let config_path = get_config_path_from(&base_dir);

        let config = load_config_at(&config_path).expect("load_config_at should create config");

        assert!(config_path.exists());
        assert!(config.projects.is_empty());
    }

    #[test]
    fn save_config_writes_data_correctly() {
        let base_dir = test_base_dir("save-config");
        let config_path = get_config_path_from(&base_dir);
        let config = Config {
            projects: vec![Project {
                name: String::from("api"),
                path: String::from("/tmp/api"),
                commands: Some(vec![String::from("npm run dev"), String::from("npm test")]),
            }],
        };

        save_config_at(&config_path, &config).expect("save_config_at should write config");
        let loaded = load_config_at(&config_path).expect("load_config_at should read saved config");

        assert_eq!(loaded.projects.len(), 1);
        assert_eq!(loaded.projects[0].name, "api");
        assert_eq!(loaded.projects[0].path, "/tmp/api");
        assert_eq!(
            loaded.projects[0].commands,
            Some(vec![String::from("npm run dev"), String::from("npm test")])
        );
    }

    #[test]
    fn load_config_recovers_from_corrupted_file() {
        let base_dir = test_base_dir("corrupted-config");
        let config_path = get_config_path_from(&base_dir);
        let config_dir = config_path
            .parent()
            .expect("config path should have a parent directory");

        fs::create_dir_all(config_dir).expect("test config directory should be created");
        fs::write(&config_path, "{ invalid json").expect("test config file should be written");

        let config = load_config_at(&config_path).expect("corrupted config should be recovered");

        assert!(config.projects.is_empty());
        assert!(config_path.exists());
        assert!(config_path.with_extension("json.bak").exists());
    }
}