envoy-cli 0.5.0

A Git-like CLI for managing encrypted environment files
use std::{
    collections::{HashMap, VecDeque},
    io::{self, IsTerminal, Read},
    sync::{Mutex, OnceLock},
};

use anyhow::{Context, Result, bail};

use crate::utils::paths::normalize_path;

const KNOWN_KEYS: &[&str] = &[
    "author",
    "count",
    "github",
    "input",
    "message",
    "name",
    "nickname",
    "passphrase",
    "remote",
    "url",
    "user_id",
];

#[derive(Default)]
struct AutomationInput {
    values: HashMap<String, VecDeque<String>>,
    fallback: VecDeque<String>,
    file_passphrases: HashMap<String, String>,
    file_passphrase_all: Option<String>,
    file_passphrase_overrides: HashMap<String, String>,
    file_passphrase_all_override: Option<String>,
}

impl AutomationInput {
    fn parse(input: &str) -> Result<Self> {
        let mut parsed = Self::default();

        for (index, raw_line) in input.lines().enumerate() {
            let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
            if line.is_empty() {
                continue;
            }

            let Some((raw_key, value)) = line.split_once('=') else {
                parsed.fallback.push_back(line.to_string());
                continue;
            };

            let key = raw_key.trim();
            if let Some(path) = key.strip_prefix("file:") {
                if path.is_empty() {
                    bail!("Piped input line {} has an empty file path", index + 1);
                }

                if path == "*" {
                    parsed.file_passphrase_all = Some(value.to_string());
                } else {
                    parsed
                        .file_passphrases
                        .insert(normalize_path(path), value.to_string());
                }
                continue;
            }

            let key = key.to_ascii_lowercase();
            if !KNOWN_KEYS.contains(&key.as_str()) {
                bail!(
                    "Unknown piped input key '{}' on line {}",
                    raw_key,
                    index + 1
                );
            }

            parsed
                .values
                .entry(key)
                .or_default()
                .push_back(value.to_string());
        }

        Ok(parsed)
    }

    fn take_value(&mut self, key: &str) -> Option<String> {
        self.values
            .get_mut(&key.to_ascii_lowercase())
            .and_then(VecDeque::pop_front)
    }

    fn take_value_or_fallback(&mut self, key: &str) -> Option<String> {
        self.take_value(key).or_else(|| self.fallback.pop_front())
    }

    fn take_file_passphrase(&mut self, path: &str) -> Option<String> {
        let path = normalize_path(path);
        self.file_passphrase_overrides
            .remove(&path)
            .or_else(|| self.file_passphrase_all_override.clone())
            .or_else(|| self.file_passphrases.remove(&path))
            .or_else(|| self.file_passphrase_all.clone())
            .or_else(|| self.fallback.pop_front())
    }
}

static AUTOMATION_INPUT: OnceLock<Mutex<AutomationInput>> = OnceLock::new();

fn input() -> &'static Mutex<AutomationInput> {
    AUTOMATION_INPUT.get_or_init(|| Mutex::new(AutomationInput::default()))
}

pub fn initialize() -> Result<()> {
    if io::stdin().is_terminal() {
        return Ok(());
    }

    let mut buffer = String::new();
    io::stdin()
        .read_to_string(&mut buffer)
        .context("Failed to read piped input")?;

    let parsed = AutomationInput::parse(&buffer)?;
    let _ = AUTOMATION_INPUT.set(Mutex::new(parsed));
    Ok(())
}

pub fn take_value(key: &str) -> Option<String> {
    input().lock().ok()?.take_value(key)
}

pub fn take_value_or_fallback(key: &str) -> Option<String> {
    input().lock().ok()?.take_value_or_fallback(key)
}

pub fn take_fallback() -> Option<String> {
    input().lock().ok()?.fallback.pop_front()
}

pub fn set_file_passphrase_overrides(
    entries: impl IntoIterator<Item = (String, String)>,
    all: Option<String>,
) {
    if let Ok(mut input) = input().lock() {
        for (path, passphrase) in entries {
            input
                .file_passphrase_overrides
                .insert(normalize_path(&path), passphrase);
        }

        if all.is_some() {
            input.file_passphrase_all_override = all;
        }
    }
}

pub fn take_file_passphrase(path: &str) -> Option<String> {
    input().lock().ok()?.take_file_passphrase(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_named_values_and_preserves_equals_in_values() {
        let mut input = AutomationInput::parse(
            "passphrase=project=secret\nfile:.env=file=secret\nmessage=Ship it\n",
        )
        .unwrap();

        assert_eq!(
            input.take_value("passphrase").as_deref(),
            Some("project=secret")
        );
        assert_eq!(
            input.take_file_passphrase("./.env").as_deref(),
            Some("file=secret")
        );
        assert_eq!(input.take_value("message").as_deref(), Some("Ship it"));
    }

    #[test]
    fn uses_exact_file_passphrase_before_wildcard() {
        let mut input =
            AutomationInput::parse("file:*=shared\nfile:config/.env=specific\n").unwrap();

        assert_eq!(
            input.take_file_passphrase(".\\config\\.env").as_deref(),
            Some("specific")
        );
        assert_eq!(
            input.take_file_passphrase(".env.local").as_deref(),
            Some("shared")
        );
    }

    #[test]
    fn command_line_file_passphrases_override_piped_values() {
        let mut input =
            AutomationInput::parse("file:*=piped-shared\nfile:.env=piped-specific\n").unwrap();
        input.file_passphrase_all_override = Some("argument-shared".to_string());
        input
            .file_passphrase_overrides
            .insert(normalize_path(".env"), "argument-specific".to_string());

        assert_eq!(
            input.take_file_passphrase(".env").as_deref(),
            Some("argument-specific")
        );
        assert_eq!(
            input.take_file_passphrase(".env.production").as_deref(),
            Some("argument-shared")
        );
    }

    #[test]
    fn retains_plain_lines_as_positional_fallbacks() {
        let mut input = AutomationInput::parse("first\nsecond\n").unwrap();

        assert_eq!(
            input.take_value_or_fallback("name").as_deref(),
            Some("first")
        );
        assert_eq!(
            input.take_value_or_fallback("passphrase").as_deref(),
            Some("second")
        );
    }

    #[test]
    fn rejects_unknown_named_keys() {
        let error = AutomationInput::parse("pasphrase=typo\n")
            .err()
            .unwrap()
            .to_string();

        assert!(error.contains("Unknown piped input key 'pasphrase'"));
    }
}