eenv 0.1.2

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

#[derive(Debug)]
pub struct GitignoreEdit {
    pub path: PathBuf,
    pub added: Vec<String>,
    pub removed: Vec<String>,
    pub changed: bool,
}

pub fn pattern_core(line: &str) -> &str {
    let mut core = line;
    if let Some(hash) = line.find('#') {
        core = &line[..hash];
    }
    core.trim()
}

fn banned_env_ignores() -> &'static [&'static str] {
    &[
        ".env.example",
        ".env*.example",
        ".env.*.example",
        "*.env.example",
        ".env.enc",
        ".env*.enc",
        ".env.*.enc",
        "*.env.enc",
    ]
}

fn to_gitignore_rel_pattern(abs: &Path, root: &Path) -> Option<String> {
    let rel = abs.strip_prefix(root).ok()?;
    let s = rel.to_string_lossy().replace('\\', "/");
    Some(if s.is_empty() {
        String::from("/")
    } else {
        s.replace(' ', r"\ ")
    })
}

pub fn fix_gitignore_from_found(
    project_root: &Path,
    real_env_files: &[PathBuf],
) -> io::Result<GitignoreEdit> {
    let root = super::util::find_repo_root(project_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 banned: HashSet<&'static str> = banned_env_ignores().iter().copied().collect();
    let mut removed = Vec::new();
    lines.retain(|line| {
        let core = pattern_core(line);
        if !core.is_empty() && banned.contains(core) {
            removed.push(line.clone());
            false
        } else {
            true
        }
    });

    let mut required: BTreeSet<String> = BTreeSet::new();
    for abs in real_env_files {
        let Some(fname) = abs.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if fname.ends_with(".example") || fname.ends_with(".enc") {
            continue;
        }
        if let Some(pat) = to_gitignore_rel_pattern(abs, &root) {
            required.insert(pat);
        }
    }
    required.insert(".githooks".to_string());
    required.insert("eenv.config.json".to_string());

    let existing: HashSet<String> = lines.iter().map(|l| pattern_core(l).to_string()).collect();
    let mut added = Vec::new();
    let missing: Vec<String> = required
        .into_iter()
        .filter(|r| !existing.contains(r))
        .collect();

    if !missing.is_empty() {
        if !lines.is_empty() && !lines.last().unwrap().trim().is_empty() {
            lines.push(String::new());
        }
        lines.push("# added by eenv".to_string());
        for m in &missing {
            lines.push(m.clone());
        }
        added.extend(missing);
    }

    let new_text = {
        let mut s = lines.join("\n");
        if !s.ends_with('\n') {
            s.push('\n');
        }
        s
    };

    let changed = new_text != original;
    if changed {
        let tmp = path.with_extension("tmp~");
        {
            let mut f = std::fs::File::create(&tmp)?;
            use std::io::Write;
            f.write_all(new_text.as_bytes())?;
            f.sync_all()?;
        }
        fs::rename(tmp, &path)?;
    }

    Ok(GitignoreEdit {
        path,
        added,
        removed,
        changed,
    })
}