lade 0.15.3

Automatically load secrets from your preferred vault as environment variables, and clear them once your shell command is over.
#[cfg(test)]
mod tests {
    use crate::config::*;
    use tempfile::tempdir;

    #[test]
    fn test_collect_exact_match() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"terraform plan\":\n  KEY: val\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        assert_eq!(config.collect("terraform plan").len(), 1);
    }

    #[test]
    fn test_collect_regex_match() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"terraform.*\":\n  KEY: val\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        assert_eq!(config.collect("terraform plan").len(), 1);
        assert_eq!(config.collect("terraform apply").len(), 1);
        assert_eq!(config.collect("other command").len(), 0);
    }

    #[test]
    fn test_collect_disclaimers_multiple_and_deduped() {
        let dir = tempdir().unwrap();
        // Two rules match "deploy prod": one unique disclaimer each, plus a
        // duplicate shared text that must appear only once.
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"deploy\":\n  \".\":\n    disclaimer: \"Shared warning.\"\n  A: a\n\
             \"prod\":\n  \".\":\n    disclaimer: \"Shared warning.\"\n  B: b\n\
             \"deploy prod\":\n  \".\":\n    disclaimer: \"Extra warning.\"\n  C: c\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let disclaimers = config.collect_disclaimers("deploy prod");
        assert_eq!(disclaimers.len(), 2);
        assert!(disclaimers.contains(&"Shared warning.".to_string()));
        assert!(disclaimers.contains(&"Extra warning.".to_string()));
    }

    #[test]
    fn test_collect_no_match() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join("lade.yml"), "\"specific\":\n  KEY: val\n").unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        assert!(config.collect("other").is_empty());
    }

    // Shell hooks run `build` + `collect` on EVERY command, and most commands do
    // not match. This guards that common hot path against gross regressions; the
    // budget is generous (CI runners vary wildly) but still catches a 10-100x
    // slowdown. Vault resolution is intentionally excluded (it is rare and
    // network-bound).
    #[test]
    fn hot_path_build_and_no_match_is_fast() {
        use std::time::{Duration, Instant};
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"terraform .*\":\n  AWS_TOKEN: op://vault/item/field\n\"kubectl .*\":\n  KUBE_TOKEN: op://vault/item/field\n",
        )
        .unwrap();
        let path = dir.path().to_path_buf();

        for _ in 0..50 {
            let config = LadeFile::build(path.clone()).unwrap();
            assert!(config.collect("git status").is_empty());
        }

        let iters = 1000u32;
        let start = Instant::now();
        for _ in 0..iters {
            let config = LadeFile::build(path.clone()).unwrap();
            let _ = config.collect("git status --porcelain");
        }
        let per_iter = start.elapsed() / iters;

        assert!(
            per_iter < Duration::from_millis(5),
            "hot path regressed: {per_iter:?} per build+no-match (budget 5ms)"
        );
    }

    #[test]
    fn test_collect_multiple_rules_match() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd.*\":\n  KEY1: val1\n\".*\":\n  KEY2: val2\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        assert_eq!(config.collect("cmd anything").len(), 2);
    }

    #[test]
    fn test_collect_keys_env_output() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd\":\n  KEY1: val1\n  KEY2: val2\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let keys = config.collect_keys("cmd");
        let env_keys = keys.get(&None).unwrap();
        assert!(env_keys.contains(&"KEY1".to_string()));
        assert!(env_keys.contains(&"KEY2".to_string()));
    }

    #[test]
    fn test_collect_keys_file_output() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd\":\n  \".\": { file: \"secrets.json\" }\n  KEY: val\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let keys = config.collect_keys("cmd");
        let file_entries: Vec<_> = keys.into_iter().filter(|(k, _)| k.is_some()).collect();
        assert_eq!(file_entries.len(), 1);
        assert!(file_entries[0].1.contains(&"KEY".to_string()));
    }

    #[test]
    fn test_collect_keys_no_match_empty() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join("lade.yml"), "\"cmd\":\n  KEY: val\n").unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        assert!(config.collect_keys("other").is_empty());
    }

    #[test]
    fn test_all_secret_sources_collects_values() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd\":\n  KEY: plain\n  URI: op://vault/item/field\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let sources = config.all_secret_sources(&None);
        assert!(sources.contains(&"plain".to_string()));
        assert!(sources.iter().any(|s| s.starts_with("op://")));
    }

    #[test]
    fn test_collect_disclaimers() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"terraform destroy\":\n  \".\":\n    disclaimer: \"This will destroy infrastructure.\"\n  KEY: val\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let disclaimers = config.collect_disclaimers("terraform destroy");
        assert_eq!(disclaimers.len(), 1);
        assert_eq!(disclaimers[0], "This will destroy infrastructure.");
        assert!(config.collect_disclaimers("terraform plan").is_empty());
    }

    #[tokio::test]
    async fn test_collect_hydrate_fails_on_conflicting_values_for_same_output() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd.*\":\n  TOKEN: parent\n\".*\":\n  TOKEN: child\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let err = config.collect_hydrate("cmd run").await.unwrap_err();
        assert!(err.to_string().contains("conflicting value for 'TOKEN'"));
    }

    #[tokio::test]
    async fn test_collect_hydrate_allows_identical_duplicates() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("lade.yml"),
            "\"cmd.*\":\n  TOKEN: same\n\".*\":\n  TOKEN: same\n",
        )
        .unwrap();
        let config = LadeFile::build(dir.path().to_path_buf()).unwrap();
        let (vars, _, _, _) = config.collect_hydrate("cmd run").await.unwrap();
        let env = vars.get(&None::<std::path::PathBuf>).unwrap();
        assert_eq!(env.get("TOKEN").unwrap(), "same");
    }
}