eenv 0.1.2

Encrypted Env Manager: encrypts .env files, manages examples, and enforces safety via git hooks.
Documentation
use serde_json::{Value, json};
use std::{
    fs, io,
    path::{Path, PathBuf},
};

pub fn eenv_config_path(repo_root: &Path) -> PathBuf {
    repo_root.join("eenv.config.json")
}

pub fn prompt_for_key() -> io::Result<String> {
    use std::io::Write;
    print!("eenv: existing eenv.config.json is invalid.\nEnter key to use: ");
    std::io::stdout().flush()?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let key = s.trim().to_string();
    if key.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "empty key not allowed",
        ));
    }
    Ok(key)
}

pub fn validate_eenv_config(repo_root: &Path) -> io::Result<bool> {
    let path = eenv_config_path(repo_root);
    if !path.exists() {
        return Ok(false);
    }
    let text = fs::read_to_string(&path)?;
    match serde_json::from_str::<serde_json::Value>(&text) {
        Ok(v) if v.is_object() => {
            Ok(matches!(v.get("key"), Some(serde_json::Value::String(s)) if !s.is_empty()))
        }
        _ => Ok(false),
    }
}

#[derive(Debug)]
pub enum ConfigStatus {
    Created,
    Valid,
    FixedMissingKey,
    RewrittenFromInvalid { backup: PathBuf },
}

pub fn ensure_eenv_config(repo_root: &Path) -> io::Result<ConfigStatus> {
    let path = eenv_config_path(repo_root);

    if !path.exists() {
        let key = super::util::generate_key();
        let pretty = format!("{{\n  \"key\": \"{}\"\n}}\n", key);
        super::util::write_string_atomic(&path, &pretty)?;
        return Ok(ConfigStatus::Created);
    }

    let text = fs::read_to_string(&path)?;
    match serde_json::from_str::<Value>(&text) {
        Ok(mut v) => {
            if !v.is_object() {
                let backup = super::util::backup_path_with_ts(&path);
                super::util::write_string_atomic(&backup, &text)?;
                let key = super::util::generate_key();
                let pretty = format!("{{\n  \"key\": \"{}\"\n}}\n", key);
                super::util::write_string_atomic(&path, &pretty)?;
                return Ok(ConfigStatus::RewrittenFromInvalid { backup });
            }

            let needs_key = match v.get("key") {
                Some(Value::String(s)) => s.is_empty(),
                _ => true,
            };

            if needs_key {
                let key = super::util::generate_key();
                v.as_object_mut()
                    .unwrap()
                    .insert("key".into(), Value::String(key));
                let mut pretty = serde_json::to_string_pretty(&v)
                    .unwrap_or_else(|_| json!({ "key": super::util::generate_key() }).to_string());
                if !pretty.ends_with('\n') {
                    pretty.push('\n');
                }
                super::util::write_string_atomic(&path, &pretty)?;
                Ok(ConfigStatus::FixedMissingKey)
            } else {
                Ok(ConfigStatus::Valid)
            }
        }
        Err(_) => {
            let key = prompt_for_key()?;
            let backup = super::util::backup_path_with_ts(&path);
            super::util::write_string_atomic(&backup, &text)?;
            let pretty = format!("{{\n  \"key\": \"{}\"\n}}\n", key);
            super::util::write_string_atomic(&path, &pretty)?;
            Ok(ConfigStatus::RewrittenFromInvalid { backup })
        }
    }
}

pub fn write_eenv_config_with_key(repo_root: &Path, key_str: &str) -> io::Result<()> {
    let path = eenv_config_path(repo_root);
    let pretty = format!("{{\n  \"key\": \"{}\"\n}}\n", key_str);
    super::util::write_string_atomic(&path, &pretty)
}

pub fn ensure_gitignore_has_config(repo_root: &Path) -> io::Result<()> {
    let root = super::util::find_repo_root(repo_root)?;
    let path = root.join(".gitignore");

    let original = if path.exists() {
        fs::read_to_string(&path)?
    } else {
        String::new()
    };
    let mut lines: Vec<String> = if original.is_empty() {
        Vec::new()
    } else {
        original.lines().map(|s| s.to_string()).collect()
    };

    let already = lines
        .iter()
        .any(|l| super::gitignore::pattern_core(l) == "eenv.config.json");
    if !already {
        if !lines.is_empty() && !lines.last().unwrap().trim().is_empty() {
            lines.push(String::new());
        }
        lines.push("# added by eenv".to_string());
        lines.push("eenv.config.json".to_string());
        let mut s = lines.join("\n");
        if !s.ends_with('\n') {
            s.push('\n');
        }
        let tmp = path.with_extension("tmp~");
        {
            let mut f = std::fs::File::create(&tmp)?;
            use std::io::Write;
            f.write_all(s.as_bytes())?;
            f.sync_all()?;
        }
        fs::rename(tmp, &path)?;
    }
    Ok(())
}

pub fn read_eenv_key(repo_root: &Path) -> io::Result<[u8; 32]> {
    let cfg_path = eenv_config_path(repo_root);
    let text = fs::read_to_string(&cfg_path)?;
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("bad eenv.config.json: {e}"),
        )
    })?;
    let key_str = v
        .get("key")
        .and_then(|x| x.as_str())
        .ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidData,
                "eenv.config.json missing non-empty \"key\"",
            )
        })?
        .trim()
        .to_string();
    if key_str.is_empty() {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "empty key"));
    }
    let hash = blake3::hash(key_str.as_bytes());
    Ok(*hash.as_bytes())
}