envseal 0.3.11

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! `.envseal` file discovery — walk-up project lookup plus global `~/.envseal`.

use std::path::Path;

use crate::error::Error;

use super::parser::{is_non_symlink_regular_file, read_envseal_nofollow, EnvMapping};

/// Discover a `.envseal` file by walking up from `start_dir` to filesystem root.
///
/// Returns the path to the first `.envseal` file found, or `None`. Mimics
/// how git discovers `.git` — walk up until you find it.
#[must_use]
pub fn discover(start_dir: &Path) -> Option<std::path::PathBuf> {
    let mut dir = start_dir.to_path_buf();
    loop {
        let candidate = dir.join(".envseal");
        if is_non_symlink_regular_file(&candidate) {
            return Some(candidate);
        }
        if !dir.pop() {
            break;
        }
    }
    None
}

/// Get the global `~/.envseal` path (user-wide bindings for agent CLIs etc.).
///
/// Returns `None` if the home directory is not set or the file does not
/// exist as a regular non-symlink file.
#[must_use]
pub fn global_envseal_path() -> Option<std::path::PathBuf> {
    let home_var = if cfg!(target_os = "windows") {
        std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME"))
    } else {
        std::env::var("HOME")
    };
    home_var
        .ok()
        .map(|h| std::path::PathBuf::from(h).join(".envseal"))
        .filter(|p| is_non_symlink_regular_file(p))
}

/// Discover and load mappings: project `.envseal` (merged with global `~/.envseal`).
///
/// Discovery order:
/// 1. Walk up from `start_dir` looking for `.envseal`.
/// 2. Load global `~/.envseal` if it exists.
/// 3. Merge: project mappings override global on env-var-name conflict.
///
/// Returns `None` if no `.envseal` file was found anywhere.
///
/// # Errors
/// Returns [`Error::StorageIo`] / [`Error::PolicyParse`] from the underlying
/// parser if a `.envseal` file is unreadable or malformed.
pub fn discover_and_load(start_dir: &Path) -> Result<Option<Vec<EnvMapping>>, Error> {
    let project_file = discover(start_dir);
    let global_file = global_envseal_path();

    if project_file.is_none() && global_file.is_none() {
        return Ok(None);
    }

    let mut mappings = Vec::new();

    // Load global first (lower priority).
    if let Some(ref global_path) = global_file {
        mappings = read_envseal_nofollow(global_path)?;
    }

    // Load project (higher priority — overrides global on conflict).
    if let Some(ref project_path) = project_file {
        let project_mappings = read_envseal_nofollow(project_path)?;
        merge_mappings(&mut mappings, &project_mappings);
    }

    if mappings.is_empty() {
        Ok(None)
    } else {
        Ok(Some(mappings))
    }
}

/// Merge `overrides` into `base`, replacing entries with matching `env_var`.
fn merge_mappings(base: &mut Vec<EnvMapping>, overrides: &[EnvMapping]) {
    for new_mapping in overrides {
        if let Some(existing) = base.iter_mut().find(|m| m.env_var == new_mapping.env_var) {
            existing.secret_name.clone_from(&new_mapping.secret_name);
        } else {
            base.push(new_mapping.clone());
        }
    }
}

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

    #[test]
    fn merge_project_overrides_global() {
        let mut base = vec![
            EnvMapping {
                env_var: "A".into(),
                secret_name: "global-a".into(),
            },
            EnvMapping {
                env_var: "B".into(),
                secret_name: "global-b".into(),
            },
        ];
        let overrides = vec![
            EnvMapping {
                env_var: "A".into(),
                secret_name: "project-a".into(),
            },
            EnvMapping {
                env_var: "C".into(),
                secret_name: "project-c".into(),
            },
        ];
        merge_mappings(&mut base, &overrides);
        assert_eq!(base.len(), 3);
        assert_eq!(
            base.iter().find(|m| m.env_var == "A").unwrap().secret_name,
            "project-a"
        );
        assert_eq!(
            base.iter().find(|m| m.env_var == "B").unwrap().secret_name,
            "global-b"
        );
        assert_eq!(
            base.iter().find(|m| m.env_var == "C").unwrap().secret_name,
            "project-c"
        );
    }

    #[test]
    fn discover_walks_up_directories() {
        let tmp = tempfile::tempdir().unwrap();
        let deep = tmp.path().join("a").join("b").join("c");
        std::fs::create_dir_all(&deep).unwrap();
        std::fs::write(tmp.path().join(".envseal"), "X=y\n").unwrap();
        let found = discover(&deep);
        assert!(found.is_some());
        assert_eq!(found.unwrap(), tmp.path().join(".envseal"));
    }

    #[test]
    fn discover_returns_none_when_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let deep = tmp.path().join("a").join("b");
        std::fs::create_dir_all(&deep).unwrap();
        assert!(discover(&deep).is_none());
    }
}