nvy 1.1.2

A simple command line tool for managing multiple env files (profiles) in a project.
Documentation
use anyhow::{anyhow, Result};
use std::{collections::{HashMap, HashSet}, fs};

use crate::config::{does_config_exist, get_profile_path, is_target_shell, load_config, Config, CONFIG_FILE_NAME};

const PROFILE_ENV_VAR: &str = "NV_CURRENT_PROFILE";

#[derive(Debug)]
struct EnvVar {
    key: String,
    value: Option<String>,
}

impl EnvVar {
    fn new(key: String, value: Option<String>) -> Self {
        Self { key, value }
    }

    fn is_valid(&self) -> bool {
        self.key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
    }

    fn to_shell_command(&self) -> String {
        match &self.value {
            Some(val) => format!("export {}={}", self.key, escape_shell_value(val)),
            None => format!("unset {}", self.key),
        }
    }

    fn to_env_file_line(&self) -> Option<String> {
        self.value.as_ref().map(|val| format!("{}={}", self.key, val))
    }
}

struct ExportResult {
    unset_vars: HashMap<String, EnvVar>,
    new_vars: HashMap<String, EnvVar>,
}

pub fn run_use(profiles: &Vec<String>) -> Result<()> {
    if !does_config_exist() {
        return Err(anyhow!(
            "{} does not exist in the current directory, please run `nvy init` first.",
            CONFIG_FILE_NAME
        ));
    }

    let mut result = ExportResult {
        unset_vars: HashMap::new(),
        new_vars: HashMap::new(),
    };

    let config = load_config()?;

    for profile in profiles {
        let profile_vars = export_profile(&config, profile)?;
        result.unset_vars.extend(profile_vars.unset_vars);
        result.new_vars.extend(profile_vars.new_vars);
    }

    if is_target_shell(&config) {
        for (_, var) in result.unset_vars {
            println!("{}", var.to_shell_command());
        }
        for (_, var) in result.new_vars {
            println!("{}", var.to_shell_command());
        }
        println!(
            "export {}={}",
            PROFILE_ENV_VAR,
            escape_shell_value(&profiles.join(","))
        );
    } else {
        let mut content = String::new();
        for (_, var) in result.new_vars {
            if let Some(line) = var.to_env_file_line() {
                content.push_str(&line);
                content.push('\n');
            }
        }

        fs::write(config.target, content)?;
    }

    Ok(())
}

fn export_profile(config: &Config, profile: &String) -> Result<ExportResult> {
    let new_path = get_profile_path(config, profile)?;

    if !does_file_exist(&new_path) {
        return Err(anyhow!(
            "Provided path {} under profile {} does not exist.",
            new_path,
            profile
        ));
    }

    let unset_vars = get_current_profile_vars()?
        .into_iter()
        .map(|key| (key.clone(), EnvVar::new(key, None)))
        .collect();

    let new_vars = parse_env_file(&new_path)?
        .into_iter()
        .filter(|var| var.is_valid())
        .map(|var| (var.key.clone(), var))
        .collect();

    Ok(ExportResult {
        unset_vars,
        new_vars,
    })
}

fn escape_shell_value(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn does_file_exist(path: &str) -> bool {
    fs::metadata(path).is_ok()
}

fn get_current_profile_vars() -> Result<HashSet<String>> {
    let mut vars = HashSet::new();
    
    let current_profiles = match std::env::var(PROFILE_ENV_VAR) {
        Ok(profiles) => profiles,
        Err(_) => return Ok(vars),
    };
    
    let config = match load_config() {
        Ok(cfg) => cfg,
        Err(_) => return Ok(vars),
    };
    
    for profile in current_profiles.split(',') {
        let path = match get_profile_path(&config, &profile.to_string()) {
            Ok(p) => p,
            Err(_) => continue,
        };
        
        let contents = match fs::read_to_string(&path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        
        for line in parse_env_line(&contents) {
            if line.is_valid() {
                vars.insert(line.key);
            }
        }
    }
    
    Ok(vars)
}

fn parse_env_file(path: &str) -> Result<Vec<EnvVar>> {
    let contents = fs::read_to_string(path)?;
    Ok(parse_env_line(&contents).collect())
}

fn parse_env_line(contents: &str) -> impl Iterator<Item = EnvVar> + '_ {
    contents.lines().filter_map(|line| {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            return None;
        }

        let (key, value) = line.split_once('=')?;
        Some(EnvVar::new(key.trim().to_string(), Some(value.trim().to_string())))
    })
}