vlt 0.1.1

Fast offline-first CLI for managing .env files across environments
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RuleType {
    #[default]
    String,
    Int,
    Float,
    Bool,
    Enum,
    Secret,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VarRule {
    #[serde(rename = "type")]
    pub rule_type: RuleType,
    #[serde(default)]
    pub required: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub values: Option<Vec<String>>,
}

impl VarRule {
    pub fn discovered() -> Self {
        Self {
            rule_type: RuleType::String,
            required: false,
            default: None,
            description: Some("".to_owned()),
            min: None,
            max: None,
            values: None,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VltRules {
    #[serde(default)]
    pub vars: BTreeMap<String, VarRule>,
}

impl VltRules {
    pub fn load(path: &Path) -> Result<Self> {
        let contents = fs::read_to_string(path)
            .with_context(|| format!("failed to read {}", path.display()))?;

        if contents.trim().is_empty() {
            return Ok(Self::default());
        }

        toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
    }

    pub fn load_or_default(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }

        Self::load(path)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }

        let contents = toml::to_string_pretty(self).context("failed to serialize rules")?;
        fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
    }

    pub fn scaffold_env_file(&self) -> String {
        let mut lines = Vec::new();
        for key in self.vars.keys() {
            lines.push(format!("{key}="));
        }
        if lines.is_empty() { String::new() } else { format!("{}\n", lines.join("\n")) }
    }

    pub fn missing_values(&self, values: &BTreeMap<String, String>) -> Vec<String> {
        self.vars
            .keys()
            .filter(|key| values.get(*key).is_none_or(|value| value.trim().is_empty()))
            .cloned()
            .collect()
    }

    pub fn unknown_keys(&self, values: &BTreeMap<String, String>) -> Vec<String> {
        values.keys().filter(|key| !self.vars.contains_key(*key)).cloned().collect()
    }
}