dotlock-bin 1.0.1

Encrypted project-local environment variables manager
use std::{
    fs::{self, OpenOptions},
    io::Write,
    path::Path,
    process::Command,
};

use crate::domain::{error::DotLockError, model::DotLockResult};

const ATTRIBUTES: &[&str] = &[
    ".lock/secrets.lock merge=dotlock",
    ".lock/vault.toml merge=dotlock",
    // FG3: env-scoped vault pairs under `.lock/envs/<env>/`.
    ".lock/envs/*/secrets.lock merge=dotlock",
    ".lock/envs/*/vault.toml merge=dotlock",
];

pub fn install_merge_driver_if_in_git_repo() -> DotLockResult<bool> {
    if !is_git_repo()? {
        return Ok(false);
    }

    install_gitattributes()?;
    configure_merge_driver()?;
    Ok(true)
}

pub fn is_git_repo() -> DotLockResult<bool> {
    let output = Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .output()
        .map_err(|err| DotLockError::Io(err.to_string()))?;

    Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true")
}

/// True when git already ignores `path` (L6). `git check-ignore -q` exits 0
/// for ignored paths, 1 for tracked/unignored ones and 128 on error; only a
/// clean 0 counts as "safe".
pub fn is_path_gitignored(path: &Path) -> DotLockResult<bool> {
    let output = Command::new("git")
        .args(["check-ignore", "-q", "--"])
        .arg(path)
        .output()
        .map_err(|err| DotLockError::Io(err.to_string()))?;

    Ok(output.status.success())
}

/// Appends `line` to the repository's `.gitignore` (creating it if needed),
/// mirroring the `.gitattributes` handling above. No-op when the exact line
/// is already present.
pub fn append_gitignore_line(line: &str) -> DotLockResult<()> {
    let path = Path::new(".gitignore");
    let existing = if path.exists() {
        fs::read_to_string(path).map_err(DotLockError::from)?
    } else {
        String::new()
    };

    if existing.lines().any(|existing| existing.trim() == line) {
        return Ok(());
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(DotLockError::from)?;

    if !existing.is_empty() && !existing.ends_with('\n') {
        writeln!(file).map_err(DotLockError::from)?;
    }
    writeln!(file, "{line}").map_err(DotLockError::from)?;

    Ok(())
}

fn install_gitattributes() -> DotLockResult<()> {
    let path = Path::new(".gitattributes");
    let existing = if path.exists() {
        fs::read_to_string(path).map_err(DotLockError::from)?
    } else {
        String::new()
    };

    let missing: Vec<&str> = ATTRIBUTES
        .iter()
        .copied()
        .filter(|line| !existing.lines().any(|existing| existing.trim() == *line))
        .collect();

    if missing.is_empty() {
        return Ok(());
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(DotLockError::from)?;

    if !existing.is_empty() && !existing.ends_with('\n') {
        writeln!(file).map_err(DotLockError::from)?;
    }

    for line in missing {
        writeln!(file, "{line}").map_err(DotLockError::from)?;
    }

    Ok(())
}

fn configure_merge_driver() -> DotLockResult<()> {
    // `%P` (the worktree pathname of the result) routes env-scoped pairs
    // (FG3); the argument is optional in `dl _git-merge`, so clones still
    // carrying the pre-FG3 3-arg config keep merging the default env.
    run_git_config(["merge.dotlock.driver", "dl _git-merge %A %B %O %P"])?;
    run_git_config(["merge.dotlock.name", "DotLock vault merge driver"])
}

fn run_git_config(args: [&str; 2]) -> DotLockResult<()> {
    let status = Command::new("git")
        .args(["config", args[0], args[1]])
        .status()
        .map_err(|err| DotLockError::Io(err.to_string()))?;

    if !status.success() {
        return Err(DotLockError::Io(format!(
            "git config failed for {}",
            args[0]
        )));
    }

    Ok(())
}