From b2a087f6cfba80c4aa9c13f6d260185f5690f321 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 6 Nov 2026 15:58:40 -0800 Subject: [PATCH 1/4] wip --- Cargo.lock | 336 ++++++++++++++++++++++++++++++++++++++++- ext/dotenvx/Cargo.toml | 2 +- 2 files changed, 335 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d20e119..66d007c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base16ct" version = "0.2.0" @@ -206,17 +212,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "dotenvx-primitives" -version = "3.0.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3437345180bc51f1355f184583a394ce06ca94ddf4f37859f6e6a42cfa29db2c" +checksum = "722e15cdb2b5a72c474a1195cfd57e695cfee02eced0bc7b308f6461f09b74f9" dependencies = [ "base64", "ecies", "hex", + "indexmap", + "num-bigint", "rand", "regex", + "url", "zeroize", ] @@ -273,6 +293,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "ff" version = "0.13.1" @@ -283,6 +309,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -358,6 +393,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hex" version = "0.4.3" @@ -382,6 +423,120 @@ dependencies = [ "digest", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" @@ -450,6 +605,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -504,6 +665,34 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -543,6 +732,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -567,6 +762,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -807,6 +1011,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -835,6 +1045,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -857,6 +1088,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -920,6 +1169,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -940,12 +1218,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/ext/dotenvx/Cargo.toml b/ext/dotenvx/Cargo.toml index 936b7df..f394434 100644 --- a/ext/dotenvx/Cargo.toml +++ b/ext/dotenvx/Cargo.toml @@ -10,7 +10,7 @@ name = "dotenvx_native" crate-type = ["cdylib"] [dependencies] -dotenvx-primitives = "=3.0.0" +dotenvx-primitives = "=3.3.1" magnus = "0.8" rb-sys = { version = "0.9.128", default-features = false, features = ["stable-api-compiled-force"] } serde = { version = "1", features = ["derive"] } From 3fca5d8f854680bb2d129e47f2ee9e5af85dd963 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 6 Nov 2026 16:00:54 -0800 Subject: [PATCH 2/4] remove support for required keys. will move to Dotenvxfile --- README.md | 6 ------ lib/dotenvx.rb | 14 -------------- spec/dotenvx_spec.rb | 5 ----- 3 files changed, 25 deletions(-) diff --git a/README.md b/README.md index be4d279..f59c687 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,6 @@ Raise when a file is missing: Dotenvx.load!(".env") ``` -Require configuration keys: - -```ruby -Dotenvx.require_keys("DATABASE_URL", "SECRET_KEY") -``` - Encrypted values are decrypted automatically when the matching private key is available through the process environment, `.keys`, or `.env.keys`. diff --git a/lib/dotenvx.rb b/lib/dotenvx.rb index 71a8866..6cb1265 100644 --- a/lib/dotenvx.rb +++ b/lib/dotenvx.rb @@ -4,15 +4,6 @@ require "pathname" module Dotenvx - class MissingKeys < RuntimeError - attr_reader :keys - - def initialize(keys) - @keys = keys - super("Missing required configuration keys: #{keys.join(", ")}") - end - end - class ParseError < RuntimeError attr_reader :code @@ -80,11 +71,6 @@ def update(env = {}, overwrite: false) changed end - def require_keys(*keys) - missing = keys.flatten.map(&:to_s) - ENV.keys - raise MissingKeys, missing unless missing.empty? - end - private def parse_files(*filenames, overwrite: false, ignore: true, strict: false) diff --git a/spec/dotenvx_spec.rb b/spec/dotenvx_spec.rb index 9406e6f..8b3f8b7 100644 --- a/spec/dotenvx_spec.rb +++ b/spec/dotenvx_spec.rb @@ -114,11 +114,6 @@ expect { described_class.load!(missing) }.to raise_error(Errno::ENOENT) end - it "requires configuration keys" do - expect { described_class.require_keys("MISSING") } - .to raise_error(Dotenvx::MissingKeys, /MISSING/) - end - def with_env_file(contents) Dir.mktmpdir do |directory| path = File.join(directory, ".env") From 18563e7282071e8331f5438d9450b67748854d3c Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 6 Nov 2026 18:42:41 -0800 Subject: [PATCH 3/4] wire up language loader --- Cargo.lock | 18 ++++-- DEVELOPMENT.md | 21 +++++-- README.md | 27 +++++++- ext/dotenvx/Cargo.toml | 3 +- ext/dotenvx/src/lib.rs | 54 +++------------- lib/dotenvx.rb | 90 +++++++++++--------------- lib/dotenvx/rails.rb | 15 +---- spec/dotenvx/rails_spec.rb | 20 ++++++ spec/dotenvx_spec.rb | 27 +++++++- spec/language_loader_spec.rb | 118 +++++++++++++++++++++++++++++++++++ 10 files changed, 262 insertions(+), 131 deletions(-) create mode 100644 spec/language_loader_spec.rb diff --git a/Cargo.lock b/Cargo.lock index 66d007c..7d46687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,11 +223,19 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "dotenvx-ll" +version = "0.1.0" +dependencies = [ + "dotenvx-primitives", + "serde", +] + [[package]] name = "dotenvx-primitives" -version = "3.3.1" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722e15cdb2b5a72c474a1195cfd57e695cfee02eced0bc7b308f6461f09b74f9" +checksum = "b34a9d59e759d37f407d58419205e05f8912bc112b0c7a889ca71b1940848a16" dependencies = [ "base64", "ecies", @@ -236,6 +244,7 @@ dependencies = [ "num-bigint", "rand", "regex", + "serde", "url", "zeroize", ] @@ -244,10 +253,9 @@ dependencies = [ name = "dotenvx_native" version = "4.0.6" dependencies = [ - "dotenvx-primitives", + "dotenvx-ll", "magnus", "rb-sys", - "serde", "serde_json", ] @@ -535,6 +543,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e48fda5..d9fe123 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,7 @@ # Development -`dotenvx` is a mixed Ruby/Rust package. Magnus exposes the published -`dotenvx-primitives` crate to Ruby, and `rb-sys` compiles the extension into +`dotenvx` is a mixed Ruby/Rust package. Magnus exposes the private +`dotenvx-ll` shared loader to Ruby, and `rb-sys` compiles the extension into `lib/dotenvx`. ## Setup @@ -44,9 +44,8 @@ cargo check bundle install ``` -The `dotenvx-primitives` dependency is versioned independently. Update it in -`ext/dotenvx/Cargo.toml` only when the gem should embed a newer primitives -release. +The shared loader owns the published `dotenvx-primitives` dependency. Update +it in Forge when the consumers should embed a newer primitives release. ## Publishing @@ -73,3 +72,15 @@ git push origin v4.0.0 ``` The tag must match `Dotenvx::VERSION`. CI verifies this before packaging. + +## Shared loader checkout + +The native extension currently depends on private `dotenvx-ll` through a local +Cargo path. Keep this checkout beside `forge/`, which contains `dotenvx-ll/`. +It embeds published primitives 4.x through that loader, instead of depending on +primitives directly. Rebuild the extension after changing the loader. + +These are local development paths, not a finished release-distribution setup. +Standalone CI/source-package builds need private access to the loader source +before publishing. Do not publish `dotenvx-ll` publicly; compiled platform gems +and wheels can embed it without distributing its Rust source. diff --git a/README.md b/README.md index f59c687..21a5aae 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@ *a secure dotenv–from the creator of [`dotenv`](https://github.com/motdotla/dotenv).* -`dotenvx` loads plaintext and encrypted dotenv files through the shared -[`dotenvx-primitives`](https://crates.io/crates/dotenvx-primitives) Rust -implementation. The native extension is bundled in the gem; users do not need +`dotenvx` loads plaintext and encrypted dotenv files through the shared `dotenvx-ll` Rust loader, backed by published +[`dotenvx-primitives`](https://crates.io/crates/dotenvx-primitives). The native extension is bundled in the gem; users do not need Node.js, a dotenvx CLI, or a Rust toolchain. ## Install @@ -75,3 +74,25 @@ Tagged releases publish variants of the same `dotenvx` gem for: - Windows x64 RubyGems selects the correct variant during `gem install dotenvx`. + +## Loading errors and policy + +Loading errors are reported and loading continues by default. `strict: true`, +`load!`, or strictness in the current directory's Dotenvxfile stops loading and +raises before updating `ENV`. Dotenvxfile required/type/file rules are shared +with the CLI. Selected error codes can be ignored with `ignore: ["CODE"]`. + +The shared loader returns redaction metadata without changing real application +values. Application-output redaction hooks are not installed by this adapter. + +## Shared file selection + +File discovery, directory expansion, conventions, and key-based filename +inference are resolved by `dotenvx-ll`. The CLI's file-selection and error-ignore +environment variables apply here too. Rails supplies its root and environment; +the shared loader chooses the Rails files and their order. + +```ruby +Dotenvx.load(convention: "nextjs") +Dotenvx.load("app", key_files: "shared/.env.keys") +``` diff --git a/ext/dotenvx/Cargo.toml b/ext/dotenvx/Cargo.toml index f394434..0e16a2d 100644 --- a/ext/dotenvx/Cargo.toml +++ b/ext/dotenvx/Cargo.toml @@ -10,8 +10,7 @@ name = "dotenvx_native" crate-type = ["cdylib"] [dependencies] -dotenvx-primitives = "=3.3.1" +dotenvx-ll = { path = "../../../forge/dotenvx-ll" } magnus = "0.8" rb-sys = { version = "0.9.128", default-features = false, features = ["stable-api-compiled-force"] } -serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/ext/dotenvx/src/lib.rs b/ext/dotenvx/src/lib.rs index beeaf2c..8ce845a 100644 --- a/ext/dotenvx/src/lib.rs +++ b/ext/dotenvx/src/lib.rs @@ -1,57 +1,21 @@ -use dotenvx_primitives::{parse, ParseOptions, ParseResult}; use magnus::{function, prelude::*, Error, Ruby}; -use serde::Deserialize; -use std::collections::HashMap; -use std::path::PathBuf; -type StringPairs = Vec<(String, String)>; -type ErrorPairs = Vec<(String, String)>; - -fn runtime_error(message: impl Into) -> Error { - let ruby = Ruby::get().expect("Ruby VM is not available"); - Error::new(ruby.exception_runtime_error(), message.into()) -} - -fn parse_result(result: ParseResult) -> (StringPairs, StringPairs, ErrorPairs) { - let errors = result - .errors - .iter() - .map(|error| (error.code().to_owned(), error.to_string())) - .collect(); - ( - result.parsed.into_iter().collect(), - result.injected.into_iter().collect(), - errors, - ) -} - -#[derive(Deserialize)] -struct ParseInput { - source: String, - process_env: HashMap, - overwrite: bool, - key_files: Vec, +fn load_env(input_json: String) -> Result { + let options = serde_json::from_str::(&input_json) + .map_err(|error| runtime_error(error.to_string()))?; + serde_json::to_string(&dotenvx_ll::load(&options)) + .map_err(|error| runtime_error(error.to_string())) } -fn parse_dotenv(input_json: String) -> Result<(StringPairs, StringPairs, ErrorPairs), Error> { - let input = serde_json::from_str::(&input_json) - .map_err(|error| runtime_error(error.to_string()))?; - let process_env = input.process_env; - Ok(parse_result(parse( - &input.source, - &ParseOptions { - process_env, - overload: input.overwrite, - key_files: input.key_files.into_iter().map(PathBuf::from).collect(), - ..Default::default() - }, - ))) +fn runtime_error(message: String) -> Error { + let ruby = Ruby::get().expect("Ruby VM is not available"); + Error::new(ruby.exception_runtime_error(), message) } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let dotenvx = ruby.define_module("Dotenvx")?; let native = dotenvx.define_module("Native")?; - native.define_singleton_method("parse_dotenv", function!(parse_dotenv, 1))?; + native.define_singleton_method("load_env", function!(load_env, 1))?; Ok(()) } diff --git a/lib/dotenvx.rb b/lib/dotenvx.rb index 6cb1265..95fb818 100644 --- a/lib/dotenvx.rb +++ b/lib/dotenvx.rb @@ -16,12 +16,15 @@ def initialize(code, message) class << self attr_accessor :instrumenter - def load(*filenames, overwrite: false, ignore: true, strict: false) + def load(*filenames, overwrite: false, ignore: [], strict: false, convention: nil, environment: nil, key_files: nil) env, injected_keys, loaded_paths = parse_files( *filenames, overwrite: overwrite, ignore: ignore, - strict: strict + strict: strict, + convention: convention, + environment: environment, + key_files: key_files ) changed = update(env, overwrite: overwrite) log_injected(injected_keys, loaded_paths) @@ -42,12 +45,15 @@ def overwrite!(*filenames) end alias overload! overwrite! - def parse(*filenames, overwrite: false, ignore: true, strict: false) + def parse(*filenames, overwrite: false, ignore: [], strict: false, convention: nil, environment: nil, key_files: nil) parse_files( *filenames, overwrite: overwrite, ignore: ignore, - strict: strict + strict: strict, + convention: convention, + environment: environment, + key_files: key_files ).first end @@ -73,53 +79,33 @@ def update(env = {}, overwrite: false) private - def parse_files(*filenames, overwrite: false, ignore: true, strict: false) - filenames = [".env"] if filenames.empty? - filenames = filenames.flatten.reverse if overwrite - - process_env = ENV.to_h - injected_keys = {} - loaded_paths = [] - values = filenames.reduce({}) do |accumulator, filename| - path = File.expand_path(filename) - begin - source = File.binread(path).sub(/\A\xEF\xBB\xBF/, "").force_encoding(Encoding::UTF_8) - rescue Errno::ENOENT, Errno::EISDIR - raise unless ignore_missing?(ignore) - next accumulator - end - - parsed, injected, errors = Native.parse_dotenv(JSON.generate( - source: source, - process_env: process_env, - overwrite: overwrite == true, - key_files: key_files(path) - )) - handle_errors(errors, ignore: ignore, strict: strict) - parsed = parsed.to_h - injected.each { |key, _value| injected_keys[key] = true } - loaded_paths << path - process_env.merge!(parsed) - accumulator.merge!(parsed) - yield Environment.new(path, parsed) if block_given? - accumulator - end - [values, injected_keys.keys, loaded_paths] - end - - def handle_errors(errors, ignore:, strict:) + def parse_files(*filenames, overwrite: false, ignore: [], strict: false, convention: nil, environment: nil, key_files: nil) + filenames = filenames.flatten ignored_codes = Array(ignore).grep(String) - errors.each do |code, message| - next if ignored_codes.include?(code) - - raise ParseError.new(code, message) if strict - - warn "☠ #{message}" + ignored_codes << "MISSING_ENV_FILE" if ignore == true + result = JSON.parse(Native.load_env(JSON.generate( + sources: filenames.map { |filename| { type: "file", value: filename.to_s } }, + cwd: Dir.pwd, + convention: convention, + environment_name: environment, + key_files: key_files.nil? ? nil : Array(key_files).map(&:to_s), + process_env: ENV.to_h, + overload: overwrite == true, + strict: strict, + ignore: ignored_codes, + adjacent_keys: true + ))) + result.fetch("diagnostics").each do |error| + if error.fetch("strict") + if error["code"] == "MISSING_ENV_FILE" && ignore == false + raise Errno::ENOENT, error["path"] + end + raise ParseError.new(error.fetch("code"), error.fetch("message")) + end + warn "☠ #{error.fetch("message")}" end - end - - def ignore_missing?(ignore) - ignore == true || Array(ignore).map(&:to_s).include?("MISSING_ENV_FILE") + [result.fetch("values"), result.fetch("injected").keys, + result.fetch("files").map { |file| file.fetch("filepath") }] end def log_injected(injected_keys, loaded_paths) @@ -138,12 +124,6 @@ def readable_path(path) rescue ArgumentError pathname.to_s end - - def key_files(path) - candidates = ["#{path}.keys", File.join(File.dirname(path), ".env.keys")] - candidates.select { |candidate| File.file?(candidate) } - .uniq - end end class Environment < Hash diff --git a/lib/dotenvx/rails.rb b/lib/dotenvx/rails.rb index 058efda..e730262 100644 --- a/lib/dotenvx/rails.rb +++ b/lib/dotenvx/rails.rb @@ -39,25 +39,12 @@ class Railtie < Rails::Railtie def load return if @loaded - Dotenvx.load(*dotenvx_files) + Dotenvx.load(root.to_s, convention: "rails", environment: Rails.env.to_s) @loaded = true end private - def dotenvx_files - environment = Rails.env - files = [ - root.join(".env.#{environment}.local") - ] - files << root.join(".env.local") unless environment == "test" - files.concat([ - root.join(".env.#{environment}"), - root.join(".env") - ]) - files - end - def root Pathname.new(Rails.root || ENV["RAILS_ROOT"] || Dir.pwd) end diff --git a/spec/dotenvx/rails_spec.rb b/spec/dotenvx/rails_spec.rb index f20194e..cf04452 100644 --- a/spec/dotenvx/rails_spec.rb +++ b/spec/dotenvx/rails_spec.rb @@ -1,4 +1,5 @@ require "spec_helper" +require "tmpdir" require "rails" require "dotenvx/rails" @@ -136,4 +137,23 @@ def add(*items) # end # end # end + + it "loads Rails test files through the shared selector" do + Dir.mktmpdir do |directory| + File.write(File.join(directory, ".env.test"), "LL_RAILS=shared\n") + File.write(File.join(directory, ".env.local"), "LL_RAILS_LOCAL=excluded\n") + allow(Rails).to receive(:root).and_return(Pathname.new(directory)) + railtie = Dotenvx::Railtie.instance + previously_loaded = railtie.instance_variable_get(:@loaded) + begin + railtie.instance_variable_set(:@loaded, false) + expect { railtie.load }.to output(/injected env/).to_stderr + expect(ENV["LL_RAILS"]).to eq("shared") + expect(ENV).not_to have_key("LL_RAILS_LOCAL") + ensure + railtie.instance_variable_set(:@loaded, previously_loaded) + end + end + end + end diff --git a/spec/dotenvx_spec.rb b/spec/dotenvx_spec.rb index 8b3f8b7..0ad3edf 100644 --- a/spec/dotenvx_spec.rb +++ b/spec/dotenvx_spec.rb @@ -85,7 +85,7 @@ with_env_file("HELLO=#{encrypted_world}\n") do |path| expect { described_class.load(path) } .to output( - "☠ [DECRYPTION_FAILED] could not decrypt HELLO\n" \ + "☠ [DECRYPTION_FAILED] could not decrypt HELLO. fix: [https://github.com/dotenvx/dotenvx/issues/757]\n" \ "⟐ injected env (1) from #{path}\n" ).to_stderr expect(ENV["HELLO"]).to eq(encrypted_world) @@ -106,14 +106,35 @@ end end - it "ignores missing files by default and raises through load!" do + it "warns about missing files by default and raises through load!" do missing = File.join(Dir.tmpdir, "dotenvx-does-not-exist") expect { expect(described_class.load(missing)).to eq({}) } - .to output("⟐ injected env (0)\n").to_stderr + .to output(/MISSING_ENV_FILE.*\n⟐ injected env \(0\)\n/).to_stderr expect { described_class.load!(missing) }.to raise_error(Errno::ENOENT) end + + it "infers filenames from environment key names in the shared loader" do + Dir.mktmpdir do |directory| + Dir.chdir(directory) do + ENV["DOTENV_PRIVATE_KEY_PRODUCTION"] = private_key + File.write(".env.production", "HELLO=#{encrypted_world}\n") + expect(Dotenvx.parse["HELLO"]).to eq("World") + end + end + end + + it "accepts explicit key files without Ruby-side discovery" do + Dir.mktmpdir do |directory| + Dir.chdir(directory) do + File.write(".env", "HELLO=#{encrypted_world}\n") + File.write("custom.keys", "DOTENV_PRIVATE_KEY=#{private_key}\n") + expect(Dotenvx.parse(".env", key_files: "custom.keys")["HELLO"]).to eq("World") + end + end + end + def with_env_file(contents) Dir.mktmpdir do |directory| path = File.join(directory, ".env") diff --git a/spec/language_loader_spec.rb b/spec/language_loader_spec.rb new file mode 100644 index 0000000..f2f92ce --- /dev/null +++ b/spec/language_loader_spec.rb @@ -0,0 +1,118 @@ +require "spec_helper" +require "tmpdir" +require "fileutils" + +RSpec.describe "shared language loader" do + around do |example| + Dir.mktmpdir do |directory| + Dir.chdir(directory) { example.run } + end + end + + it "loads through the native language loader without a CLI" do + File.write(".env", "LL_VALUE=World\n") + expect { Dotenvx.load }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("World") + end + + it "surfaces missing files without failing by default" do + expect { expect(Dotenvx.load).to eq({}) } + .to output(/MISSING_ENV_FILE.*missing file/).to_stderr + end + + it "takes strictness from Dotenvxfile before reading files" do + File.write("Dotenvxfile", "strict true\n") + expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /MISSING_ENV_FILE/) + end + + it "reports policy errors and still loads values by default" do + File.write(".env", "LL_PORT=invalid\nLL_VALUE=loaded\n") + File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') + expect { Dotenvx.load }.to output(/INVALID_ENV/).to_stderr + expect(ENV["LL_VALUE"]).to eq("loaded") + end + + it "raises before changing ENV for explicit strictness" do + File.write(".env", "LL_PORT=invalid\nLL_VALUE=not-loaded\n") + File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') + expect { Dotenvx.load(strict: true) }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) + expect(ENV).not_to have_key("LL_VALUE") + end + + it "honors file-specific policy strictness" do + File.write(".env", "LL_VALUE=not-loaded\n") + File.write("Dotenvxfile", "file \".env\" do\n strict true\n env \"LL_REQUIRED\", required: true\nend") + expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) + expect(ENV).not_to have_key("LL_VALUE") + end + + it "ignores selected diagnostics even under policy strictness" do + File.write(".env", "LL_VALUE=loaded\n") + File.write("Dotenvxfile", "strict true\nenv \"LL_REQUIRED\", required: true") + expect { Dotenvx.load(ignore: ["INVALID_ENV"]) }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("loaded") + end + + it "uses run-style source ordering when overloading multiple files" do + File.write("first.env", "LL_VALUE=first\n") + File.write("second.env", "LL_VALUE=second\n") + expect { Dotenvx.load("first.env", "second.env", overwrite: true) }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("second") + end + + it "returns policy redaction metadata without redacting application values" do + File.write(".env", "LL_SECRET=real-value\n") + File.write("Dotenvxfile", 'env "LL_SECRET", redacted: true') + result = JSON.parse(Dotenvx::Native.load_env(JSON.generate( + sources: [{type: "file", value: ".env"}], cwd: Dir.pwd, process_env: {} + ))) + expect(result.fetch("secrets")).to eq(["real-value"]) + expect(result.fetch("values")).to eq("LL_SECRET" => "real-value") + expect(ENV).not_to have_key("LL_SECRET") + end + + it "selects convention files in Rust and skips absent optional files" do + File.write(".env.development.local", "LL_VALUE=first\n") + File.write(".env", "LL_VALUE=base\n") + expect(Dotenvx.parse(convention: "nextjs")["LL_VALUE"]).to eq("first") + end + + it "uses environment-selected conventions and excludes local files in test" do + ENV["DOTENV_CONVENTION"] = "nextjs" + ENV["NODE_ENV"] = "test" + File.write(".env.local", "LL_LOCAL=excluded\n") + File.write(".env.test", "LL_VALUE=test\n") + expect(Dotenvx.parse).to eq("LL_VALUE" => "test") + end + + it "expands directories and finds neighboring key files in Rust" do + FileUtils.mkdir_p("app") + File.write("app/.env", "LL_VALUE=directory\n") + expect(Dotenvx.parse("app")).to eq("LL_VALUE" => "directory") + end + + it "supports Rails environments without duplicating the Rails file list" do + File.write(".env.review.local", "LL_VALUE=review\n") + File.write(".env", "LL_VALUE=base\n") + expect(Dotenvx.parse(".", convention: "rails", environment: "review")["LL_VALUE"]).to eq("review") + end + + it "uses file configuration from ENV unless filenames are supplied" do + ENV["DOTENV_FILE"] = "custom.env" + File.write("custom.env", "LL_VALUE=configured\n") + File.write("explicit.env", "LL_VALUE=explicit\n") + expect(Dotenvx.parse["LL_VALUE"]).to eq("configured") + expect(Dotenvx.parse("explicit.env")["LL_VALUE"]).to eq("explicit") + end + + it "honors ignored error codes from ENV in the shared loader" do + ENV["DOTENV_IGNORE"] = "MISSING_ENV_FILE" + expect(Dotenvx.parse("missing.env", strict: true)).to eq({}) + end + + it "reports invalid conventions through the shared diagnostics" do + expect { Dotenvx.load(convention: "unknown") } + .to raise_error(Dotenvx::ParseError, /INVALID_CONVENTION/) + end + +end From e91003afaf2ad4bbfbb068f863711934ed905ac1 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 6 Nov 2026 18:47:38 -0800 Subject: [PATCH 4/4] fix up --- spec/dotenvx_spec.rb | 115 ++++++++++++++++++++++++++++++++++ spec/language_loader_spec.rb | 118 ----------------------------------- 2 files changed, 115 insertions(+), 118 deletions(-) delete mode 100644 spec/language_loader_spec.rb diff --git a/spec/dotenvx_spec.rb b/spec/dotenvx_spec.rb index 0ad3edf..af12b2f 100644 --- a/spec/dotenvx_spec.rb +++ b/spec/dotenvx_spec.rb @@ -1,5 +1,6 @@ require "spec_helper" require "tmpdir" +require "fileutils" RSpec.describe Dotenvx do let(:private_key) { "a4547dcd9d3429615a3649bb79e87edb62ee6a74b007075e9141ae44f5fb412c" } @@ -135,6 +136,120 @@ end end + context "with files in the working directory" do + around do |example| + Dir.mktmpdir do |directory| + Dir.chdir(directory) { example.run } + end + end + + it "loads through the native language loader without a CLI" do + File.write(".env", "LL_VALUE=World\n") + expect { Dotenvx.load }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("World") + end + + it "surfaces missing files without failing by default" do + expect { expect(Dotenvx.load).to eq({}) } + .to output(/MISSING_ENV_FILE.*missing file/).to_stderr + end + + it "takes strictness from Dotenvxfile before reading files" do + File.write("Dotenvxfile", "strict true\n") + expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /MISSING_ENV_FILE/) + end + + it "reports policy errors and still loads values by default" do + File.write(".env", "LL_PORT=invalid\nLL_VALUE=loaded\n") + File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') + expect { Dotenvx.load }.to output(/INVALID_ENV/).to_stderr + expect(ENV["LL_VALUE"]).to eq("loaded") + end + + it "raises before changing ENV for explicit strictness" do + File.write(".env", "LL_PORT=invalid\nLL_VALUE=not-loaded\n") + File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') + expect { Dotenvx.load(strict: true) }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) + expect(ENV).not_to have_key("LL_VALUE") + end + + it "honors file-specific policy strictness" do + File.write(".env", "LL_VALUE=not-loaded\n") + File.write("Dotenvxfile", "file \".env\" do\n strict true\n env \"LL_REQUIRED\", required: true\nend") + expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) + expect(ENV).not_to have_key("LL_VALUE") + end + + it "ignores selected diagnostics even under policy strictness" do + File.write(".env", "LL_VALUE=loaded\n") + File.write("Dotenvxfile", "strict true\nenv \"LL_REQUIRED\", required: true") + expect { Dotenvx.load(ignore: ["INVALID_ENV"]) }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("loaded") + end + + it "uses run-style source ordering when overloading multiple files" do + File.write("first.env", "LL_VALUE=first\n") + File.write("second.env", "LL_VALUE=second\n") + expect { Dotenvx.load("first.env", "second.env", overwrite: true) }.to output(/injected env/).to_stderr + expect(ENV["LL_VALUE"]).to eq("second") + end + + it "returns policy redaction metadata without redacting application values" do + File.write(".env", "LL_SECRET=real-value\n") + File.write("Dotenvxfile", 'env "LL_SECRET", redacted: true') + result = JSON.parse(Dotenvx::Native.load_env(JSON.generate( + sources: [{type: "file", value: ".env"}], cwd: Dir.pwd, process_env: {} + ))) + expect(result.fetch("secrets")).to eq(["real-value"]) + expect(result.fetch("values")).to eq("LL_SECRET" => "real-value") + expect(ENV).not_to have_key("LL_SECRET") + end + + it "selects convention files in Rust and skips absent optional files" do + File.write(".env.development.local", "LL_VALUE=first\n") + File.write(".env", "LL_VALUE=base\n") + expect(Dotenvx.parse(convention: "nextjs")["LL_VALUE"]).to eq("first") + end + + it "uses environment-selected conventions and excludes local files in test" do + ENV["DOTENV_CONVENTION"] = "nextjs" + ENV["NODE_ENV"] = "test" + File.write(".env.local", "LL_LOCAL=excluded\n") + File.write(".env.test", "LL_VALUE=test\n") + expect(Dotenvx.parse).to eq("LL_VALUE" => "test") + end + + it "expands directories and finds neighboring key files in Rust" do + FileUtils.mkdir_p("app") + File.write("app/.env", "LL_VALUE=directory\n") + expect(Dotenvx.parse("app")).to eq("LL_VALUE" => "directory") + end + + it "supports Rails environments without duplicating the Rails file list" do + File.write(".env.review.local", "LL_VALUE=review\n") + File.write(".env", "LL_VALUE=base\n") + expect(Dotenvx.parse(".", convention: "rails", environment: "review")["LL_VALUE"]).to eq("review") + end + + it "uses file configuration from ENV unless filenames are supplied" do + ENV["DOTENV_FILE"] = "custom.env" + File.write("custom.env", "LL_VALUE=configured\n") + File.write("explicit.env", "LL_VALUE=explicit\n") + expect(Dotenvx.parse["LL_VALUE"]).to eq("configured") + expect(Dotenvx.parse("explicit.env")["LL_VALUE"]).to eq("explicit") + end + + it "honors ignored error codes from ENV in the shared loader" do + ENV["DOTENV_IGNORE"] = "MISSING_ENV_FILE" + expect(Dotenvx.parse("missing.env", strict: true)).to eq({}) + end + + it "reports invalid conventions through the shared diagnostics" do + expect { Dotenvx.load(convention: "unknown") } + .to raise_error(Dotenvx::ParseError, /INVALID_CONVENTION/) + end + end + def with_env_file(contents) Dir.mktmpdir do |directory| path = File.join(directory, ".env") diff --git a/spec/language_loader_spec.rb b/spec/language_loader_spec.rb deleted file mode 100644 index f2f92ce..0000000 --- a/spec/language_loader_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -require "spec_helper" -require "tmpdir" -require "fileutils" - -RSpec.describe "shared language loader" do - around do |example| - Dir.mktmpdir do |directory| - Dir.chdir(directory) { example.run } - end - end - - it "loads through the native language loader without a CLI" do - File.write(".env", "LL_VALUE=World\n") - expect { Dotenvx.load }.to output(/injected env/).to_stderr - expect(ENV["LL_VALUE"]).to eq("World") - end - - it "surfaces missing files without failing by default" do - expect { expect(Dotenvx.load).to eq({}) } - .to output(/MISSING_ENV_FILE.*missing file/).to_stderr - end - - it "takes strictness from Dotenvxfile before reading files" do - File.write("Dotenvxfile", "strict true\n") - expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /MISSING_ENV_FILE/) - end - - it "reports policy errors and still loads values by default" do - File.write(".env", "LL_PORT=invalid\nLL_VALUE=loaded\n") - File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') - expect { Dotenvx.load }.to output(/INVALID_ENV/).to_stderr - expect(ENV["LL_VALUE"]).to eq("loaded") - end - - it "raises before changing ENV for explicit strictness" do - File.write(".env", "LL_PORT=invalid\nLL_VALUE=not-loaded\n") - File.write("Dotenvxfile", 'env "LL_PORT", type: "port"') - expect { Dotenvx.load(strict: true) }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) - expect(ENV).not_to have_key("LL_VALUE") - end - - it "honors file-specific policy strictness" do - File.write(".env", "LL_VALUE=not-loaded\n") - File.write("Dotenvxfile", "file \".env\" do\n strict true\n env \"LL_REQUIRED\", required: true\nend") - expect { Dotenvx.load }.to raise_error(Dotenvx::ParseError, /INVALID_ENV/) - expect(ENV).not_to have_key("LL_VALUE") - end - - it "ignores selected diagnostics even under policy strictness" do - File.write(".env", "LL_VALUE=loaded\n") - File.write("Dotenvxfile", "strict true\nenv \"LL_REQUIRED\", required: true") - expect { Dotenvx.load(ignore: ["INVALID_ENV"]) }.to output(/injected env/).to_stderr - expect(ENV["LL_VALUE"]).to eq("loaded") - end - - it "uses run-style source ordering when overloading multiple files" do - File.write("first.env", "LL_VALUE=first\n") - File.write("second.env", "LL_VALUE=second\n") - expect { Dotenvx.load("first.env", "second.env", overwrite: true) }.to output(/injected env/).to_stderr - expect(ENV["LL_VALUE"]).to eq("second") - end - - it "returns policy redaction metadata without redacting application values" do - File.write(".env", "LL_SECRET=real-value\n") - File.write("Dotenvxfile", 'env "LL_SECRET", redacted: true') - result = JSON.parse(Dotenvx::Native.load_env(JSON.generate( - sources: [{type: "file", value: ".env"}], cwd: Dir.pwd, process_env: {} - ))) - expect(result.fetch("secrets")).to eq(["real-value"]) - expect(result.fetch("values")).to eq("LL_SECRET" => "real-value") - expect(ENV).not_to have_key("LL_SECRET") - end - - it "selects convention files in Rust and skips absent optional files" do - File.write(".env.development.local", "LL_VALUE=first\n") - File.write(".env", "LL_VALUE=base\n") - expect(Dotenvx.parse(convention: "nextjs")["LL_VALUE"]).to eq("first") - end - - it "uses environment-selected conventions and excludes local files in test" do - ENV["DOTENV_CONVENTION"] = "nextjs" - ENV["NODE_ENV"] = "test" - File.write(".env.local", "LL_LOCAL=excluded\n") - File.write(".env.test", "LL_VALUE=test\n") - expect(Dotenvx.parse).to eq("LL_VALUE" => "test") - end - - it "expands directories and finds neighboring key files in Rust" do - FileUtils.mkdir_p("app") - File.write("app/.env", "LL_VALUE=directory\n") - expect(Dotenvx.parse("app")).to eq("LL_VALUE" => "directory") - end - - it "supports Rails environments without duplicating the Rails file list" do - File.write(".env.review.local", "LL_VALUE=review\n") - File.write(".env", "LL_VALUE=base\n") - expect(Dotenvx.parse(".", convention: "rails", environment: "review")["LL_VALUE"]).to eq("review") - end - - it "uses file configuration from ENV unless filenames are supplied" do - ENV["DOTENV_FILE"] = "custom.env" - File.write("custom.env", "LL_VALUE=configured\n") - File.write("explicit.env", "LL_VALUE=explicit\n") - expect(Dotenvx.parse["LL_VALUE"]).to eq("configured") - expect(Dotenvx.parse("explicit.env")["LL_VALUE"]).to eq("explicit") - end - - it "honors ignored error codes from ENV in the shared loader" do - ENV["DOTENV_IGNORE"] = "MISSING_ENV_FILE" - expect(Dotenvx.parse("missing.env", strict: true)).to eq({}) - end - - it "reports invalid conventions through the shared diagnostics" do - expect { Dotenvx.load(convention: "unknown") } - .to raise_error(Dotenvx::ParseError, /INVALID_CONVENTION/) - end - -end