envoy-cli 0.2.8

A Git-like CLI for managing encrypted environment files
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;

use crate::commands::crypto::{decrypt_bytes_with_key, encrypt_bytes_with_key};

use super::manifest::get_project_key;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
    pub version: u8,
    pub parent: Option<String>,
    pub timestamp: u64,
    pub message: String,
    pub manifest_hash: String,
    pub author: Option<String>,
}

impl Commit {
    pub fn new(
        parent: Option<String>,
        message: String,
        manifest_hash: String,
        author: Option<String>,
    ) -> Self {
        use std::time::{SystemTime, UNIX_EPOCH};
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();

        Self {
            version: 1,
            parent,
            timestamp,
            message,
            manifest_hash,
            author,
        }
    }
}

const COMMITS_DIR: &str = ".envoy/cache/commits";
const HEAD_PATH: &str = ".envoy/HEAD";
const REMOTE_HEAD_PATH: &str = ".envoy/refs/remotes/origin/HEAD";

pub fn read_head() -> Option<String> {
    fs::read_to_string(HEAD_PATH)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

pub fn write_head(commit_hash: &str) -> Result<()> {
    if let Some(parent) = Path::new(HEAD_PATH).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(HEAD_PATH, commit_hash)?;
    Ok(())
}

pub fn read_remote_head() -> Option<String> {
    fs::read_to_string(REMOTE_HEAD_PATH)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

pub fn write_remote_head(commit_hash: &str) -> Result<()> {
    if let Some(parent) = Path::new(REMOTE_HEAD_PATH).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(REMOTE_HEAD_PATH, commit_hash)?;
    Ok(())
}

pub fn save_commit(commit: &Commit) -> Result<String> {
    let manifest_key = get_project_key()?;

    let plaintext = serde_json::to_vec(commit)
        .map_err(|e| anyhow::anyhow!("Failed to serialize commit: {}", e))?;

    let encrypted = encrypt_bytes_with_key(&plaintext, &manifest_key)?;

    let mut hasher = Sha256::new();
    hasher.update(&encrypted);
    let hash_hex = hex::encode(hasher.finalize());

    fs::create_dir_all(COMMITS_DIR)
        .map_err(|e| anyhow::anyhow!("Failed to create commits directory: {}", e))?;

    let path = format!("{}/{}.blob", COMMITS_DIR, hash_hex);
    fs::write(&path, encrypted)
        .map_err(|e| anyhow::anyhow!("Failed to write commit {}: {}", &hash_hex[..8], e))?;

    Ok(hash_hex)
}

pub fn save_commit_blob(commit_hash: &str, encrypted_data: &[u8]) -> Result<()> {
    fs::create_dir_all(COMMITS_DIR)?;
    let path = format!("{}/{}.blob", COMMITS_DIR, commit_hash);
    fs::write(&path, encrypted_data)?;
    Ok(())
}

pub fn load_commit(commit_hash: &str) -> Result<Commit> {
    let manifest_key = get_project_key()?;

    let path = format!("{}/{}.blob", COMMITS_DIR, commit_hash);

    if !Path::new(&path).exists() {
        bail!(
            "Commit {} not found locally. Run `envy pull` to fetch it.",
            &commit_hash[..8]
        );
    }

    let encrypted = fs::read(&path)
        .map_err(|e| anyhow::anyhow!("Failed to read commit {}: {}", &commit_hash[..8], e))?;

    let plaintext = decrypt_bytes_with_key(&encrypted, &manifest_key).map_err(|_| {
        anyhow::anyhow!(
            "Failed to decrypt commit {}. The passphrase may be incorrect.",
            &commit_hash[..8]
        )
    })?;

    let commit: Commit = serde_json::from_slice(&plaintext)
        .map_err(|e| anyhow::anyhow!("Failed to parse commit {}: {}", &commit_hash[..8], e))?;

    if commit.version != 1 {
        bail!(
            "Unsupported commit version {}. Please update envy.",
            commit.version
        );
    }

    Ok(commit)
}

pub fn commit_exists(commit_hash: &str) -> bool {
    let path = format!("{}/{}.blob", COMMITS_DIR, commit_hash);
    Path::new(&path).exists()
}

pub fn commit_blob_path(commit_hash: &str) -> std::path::PathBuf {
    Path::new(COMMITS_DIR).join(format!("{}.blob", commit_hash))
}

pub fn walk_history(start_hash: &str, limit: Option<usize>) -> Result<Vec<(String, Commit)>> {
    let mut history = Vec::new();
    let mut current = Some(start_hash.to_string());
    let mut count = 0;

    while let Some(hash) = current {
        if let Some(max) = limit
            && count >= max
        {
            break;
        }

        let commit = load_commit(&hash)?;
        current = commit.parent.clone();
        history.push((hash, commit));
        count += 1;
    }

    Ok(history)
}

pub fn commits_ahead_of_remote() -> Result<Vec<String>> {
    let local_head = match read_head() {
        Some(h) => h,
        None => return Ok(vec![]),
    };

    let remote_head = read_remote_head();

    let mut commits_to_push = Vec::new();
    let mut current = Some(local_head);

    while let Some(hash) = current {
        if Some(&hash) == remote_head.as_ref() {
            break;
        }

        commits_to_push.push(hash.clone());

        if let Ok(commit) = load_commit(&hash) {
            current = commit.parent;
        } else {
            break;
        }
    }

    Ok(commits_to_push)
}

pub fn find_common_ancestor(local_head: &str, remote_head: &str) -> Result<Option<String>> {
    let mut local_ancestors = std::collections::HashSet::new();
    let mut current = Some(local_head.to_string());

    while let Some(hash) = current {
        local_ancestors.insert(hash.clone());
        if let Ok(commit) = load_commit(&hash) {
            current = commit.parent;
        } else {
            break;
        }
    }

    let mut current = Some(remote_head.to_string());
    while let Some(hash) = current {
        if local_ancestors.contains(&hash) {
            return Ok(Some(hash));
        }
        if let Ok(commit) = load_commit(&hash) {
            current = commit.parent;
        } else {
            break;
        }
    }

    Ok(None)
}

pub fn get_head_manifest_hash() -> Option<String> {
    let head = read_head()?;
    let commit = load_commit(&head).ok()?;
    Some(commit.manifest_hash)
}