├── .env ├── .formatter.exs ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .tool-versions ├── LICENSE.md ├── README.md ├── lib ├── dotenv.ex └── dotenv │ ├── env.ex │ ├── server.ex │ └── supervisor.ex ├── mix.exs └── test ├── dotenv_app_test.exs ├── dotenv_test.exs ├── fixture ├── proj1 │ ├── .env │ └── subdir │ │ └── .gitkeep ├── proj2 │ └── .env └── proj3 │ └── .env └── test_helper.exs /.env: -------------------------------------------------------------------------------- 1 | APP_TEST_VAR = HELLO 2 | 3 | # expand 4 | 5 | EXPAND_VALUE_1=${APP_TEST_VAR} 6 | EXPAND_VALUE_2=TEST_$APP_TEST_VAR 7 | EXPAND_VALUE_3=TEST_$APP_TEST_VAR_TEST 8 | EXPAND_VALUE_4=TEST_${APP_TEST_VAR}_TEST 9 | EXPAND_VALUE_5="TEST_${APP_TEST_VAR}_TEST" 10 | EXPAND_VALUE_6=TEST_${APP_TEST_VAR}_${EXPAND_VALUE_2} 11 | EXPAND_VALUE_7="TEST_${APP_TEST_VAR}_${EXPAND_VALUE_3}" 12 | 13 | # no expand 14 | 15 | SKIP_EXPAND_VALUE_REPLACE_1=TEST_\$APP_TEST_VAR 16 | SKIP_EXPAND_VALUE_REPLACE_2=TEST_\${APP_TEST_VAR} 17 | SKIP_EXPAND_VALUE_REPLACE_3=TEST_\${APP_TEST_VAR}_TEST 18 | SKIP_EXPAND_VALUE_REPLACE_4="TEST_\${APP_TEST_VAR}_TEST" 19 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Build 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 8 | strategy: 9 | matrix: 10 | otp: ['21.3', '22.2', '23.1.1'] 11 | elixir: ['1.8.2', '1.9.4', '1.10.3', '1.11.1'] 12 | exclude: 13 | - otp: '20.3' 14 | elixir: '1.11.1' 15 | - otp: '23.1.1' 16 | elixir: '1.8.2' 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-elixir@v1 20 | with: 21 | otp-version: ${{matrix.otp}} 22 | elixir-version: ${{matrix.elixir}} 23 | - run: mix deps.get 24 | - run: mix test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | dotenv-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | mix.lock 30 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 23.1.1 2 | elixir 1.11.1-otp-23 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Avdi Grimm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dotenv for Elixir 2 | 3 | [![Build](https://github.com/avdi/dotenv_elixir/actions/workflows/main.yaml/badge.svg)](https://github.com/avdi/dotenv_elixir/actions/workflows/main.yaml) 4 | [![Module Version](https://img.shields.io/hexpm/v/dotenv.svg)](https://hex.pm/packages/dotenv) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/dotenv/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/dotenv.svg)](https://hex.pm/packages/dotenv) 7 | [![License](https://img.shields.io/hexpm/l/dotenv.svg)](https://github.com/avdi/dotenv_elixir/blob/master/LICENSE.md) 8 | [![Last Updated](https://img.shields.io/github/last-commit/avdi/dotenv_elixir.svg)](https://github.com/avdi/dotenv_elixir/commits/master) 9 | 10 | This is a port of @bkeepers' [dotenv](https://github.com/bkeepers/dotenv) project to Elixir. You can read more about dotenv on that project's page. The short version is that it simplifies developing projects where configuration is stored in environment variables (e.g. projects intended to be deployed to Heroku). 11 | 12 | > WARNING: Not compatible with Elixir releases 13 | 14 | Elixir has an excellent configuration system and this dotenv implementation 15 | has a serious limitation in that it isn't available at compile time. It fits very 16 | poorly into a deployment setup using Elixir releases, distillery, or similar. 17 | 18 | Configuration management should be built around Elixir's existing configuration 19 | system. A good example is [Phoenix](http://www.phoenixframework.org/) which 20 | generates a project where the production config imports the "secrets" from a 21 | file stored outside of version control. Even if you're using this for 22 | development, the same approach could be taken. 23 | 24 | However, if you are using Heroku, Dokku, or another deployment process that does *not* use releases, read on! 25 | 26 | ## Quick Start 27 | 28 | The simplest way to use Dotenv is with the included OTP application. This will automatically load variables from a `.env` file in the root of your project directory into the process environment when started. 29 | 30 | First add `:dotenv` to your dependencies. 31 | 32 | For the latest release: 33 | 34 | ```elixir 35 | defp deps do 36 | [ 37 | {:dotenv, "~> 3.0.0"} 38 | ] 39 | end 40 | ``` 41 | 42 | Most likely, if you are deploying in a Heroku-like environment, you'll want to only load the package in a non-production environment: 43 | 44 | ```elixir 45 | {:dotenv, "~> 3.0.0", only: [:dev, :test]} 46 | ``` 47 | 48 | For master: 49 | 50 | ```elixir 51 | {:dotenv, github: "avdi/dotenv_elixir"} 52 | ``` 53 | 54 | Fetch your dependencies with `mix deps.get`. 55 | 56 | Now, when you load your app in a console with `iex -S mix`, your environment variables will be set automatically. 57 | 58 | ## Using Environment Variables in Configuration 59 | 60 | [Mix loads configuration before loading any application code.](https://github.com/elixir-lang/elixir/blob/52141f2a3fa69906397017883242948dd93d91b5/lib/mix/lib/mix/tasks/run.ex#L123) If you want to use `.env` variables in your application configuration, you'll need to load dotenv manually on application start and reload your application config: 61 | 62 | ```elixir 63 | defmodule App.Application do 64 | use Application 65 | 66 | def start(_type, _args) do 67 | unless Mix.env == :prod do 68 | Dotenv.load 69 | Mix.Task.run("loadconfig") 70 | end 71 | 72 | # ... the rest of your application startup 73 | end 74 | end 75 | ``` 76 | 77 | ## Elixir 1.9 and older 78 | 79 | If you are running an old version of Elixir, you'll need to add the `:dotenv` application to your applications list when running in the `:dev` environment: 80 | 81 | ```elixir 82 | # Configuration for the OTP application 83 | def application do 84 | [ 85 | mod: { YourApp, [] }, 86 | applications: app_list(Mix.env) 87 | ] 88 | end 89 | 90 | defp app_list(:dev), do: [:dotenv | app_list] 91 | defp app_list(_), do: app_list 92 | defp app_list, do: [...] 93 | ``` 94 | 95 | ## Reloading the `.env` file 96 | 97 | The `Dotenv.reload!/0` function will reload the variables defined in the `.env` file. 98 | 99 | More examples of the server API usage can be found in [dotenv_app_test.exs](https://github.com/avdi/dotenv_elixir/blob/master/test/dotenv_app_test.exs). 100 | 101 | ## Serverless API 102 | 103 | If you would like finer-grained control over when variables are loaded, or would like to inspect them, Dotenv also provides a serverless API for interacting with `.env` files. 104 | 105 | The `load!/1` function loads variables into the process environment, and can be passed a path or list of paths to read from. 106 | 107 | Alternately, `load/1` will return a data structure of the variables read from the `.env` file: 108 | 109 | ```elixir 110 | iex> Dotenv.load 111 | %Dotenv.Env{paths: ["/elixir/dotenv_elixir/.env"], 112 | values: %{"APP_TEST_VAR" => "HELLO"}} 113 | ``` 114 | For further details, see the inline documentation. Usage examples can be found in [dotenv_test.exs](https://github.com/avdi/dotenv_elixir/blob/master/test/dotenv_test.exs). 115 | 116 | ## Copyright and License 117 | 118 | Copyright (c) 2014 Avdi Grimm 119 | 120 | This library is released under the MIT License. See the [LICENSE.md](./LICENSE.md) file 121 | for further details. 122 | -------------------------------------------------------------------------------- /lib/dotenv.ex: -------------------------------------------------------------------------------- 1 | defmodule Dotenv do 2 | @moduledoc """ 3 | This module implements both an OTP application API and a "serverless" API. 4 | 5 | ## Server API 6 | 7 | Start the application with `start/2` On starting, it will automatically export 8 | the environment variables in the default path (`.env`). 9 | 10 | The environment can then be reloaded with `reload!/0` or a specific path 11 | or list of paths can be provided to `reload!/1`. 12 | 13 | ## Serverless API 14 | 15 | To use the serverless API, you can either load the environment variables with 16 | `load!` (again, optionally passing in a path or list of paths), or you 17 | can retrieve the variables without exporting them using `load`. 18 | """ 19 | 20 | use Application 21 | alias Dotenv.Env 22 | 23 | def start(_type, env_path \\ :automatic) do 24 | Dotenv.Supervisor.start_link(env_path) 25 | end 26 | 27 | @quotes_pattern ~r/^(['"])(.*)\1$/ 28 | @pattern ~r/ 29 | \A 30 | (?:export\s+)? # optional export 31 | ([\w\.]+) # key 32 | (?:\s*=\s*|:\s+?) # separator 33 | ( # optional value begin 34 | '(?:\'|[^'])*?' # single quoted value 35 | | # or 36 | "(?:\"|[^"])*?" # double quoted value 37 | | # or 38 | [^#\n]+? # unquoted value 39 | )? # value end 40 | (?:\s*\#.*)? # optional comment 41 | \z 42 | /x 43 | 44 | # https://regex101.com/r/XrvCwE/1 45 | @env_expand_pattern ~r/ 46 | (?:^|[^\\]) # prevent to expand \\$ 47 | ( # get variable key pattern 48 | \$ # 49 | (?: # 50 | ([A-Z0-9_]*[A-Z_]+[A-Z0-9_]*) # get variable key 51 | | # 52 | (?: # 53 | {([A-Z0-9_]*[A-Z_]+[A-Z0-9_]*)} # get variable key between {} 54 | ) # 55 | ) # 56 | ) # 57 | /x 58 | 59 | ############################################################################## 60 | # Server API 61 | ############################################################################## 62 | 63 | @doc """ 64 | Calls the server to reload the values in the `.env` file into the 65 | system environment. 66 | 67 | This call is asynchronous (`cast`). 68 | """ 69 | @spec reload!() :: :ok 70 | def reload! do 71 | :gen_server.cast(:dotenv, :reload!) 72 | end 73 | 74 | @doc """ 75 | Calls the server to reload the values in the file located at `env_path` into 76 | the system environment. 77 | 78 | This call is asynchronous (`cast`). 79 | """ 80 | @spec reload!(any) :: :ok 81 | def reload!(env_path) do 82 | :gen_server.cast(:dotenv, {:reload!, env_path}) 83 | end 84 | 85 | @doc """ 86 | Returns the current state of the server as a `Dotenv.Env` struct. 87 | """ 88 | @spec env() :: Env.t() 89 | def env do 90 | :gen_server.call(:dotenv, :env) 91 | end 92 | 93 | @doc """ 94 | Retrieves the value of the given `key` from the server, or `fallback` if the 95 | value is not found. 96 | """ 97 | @spec get(String.t(), String.t() | nil) :: String.t() 98 | def get(key, fallback \\ nil) do 99 | :gen_server.call(:dotenv, {:get, key, fallback}) 100 | end 101 | 102 | ############################################################################## 103 | # Serverless API 104 | ############################################################################## 105 | 106 | @doc """ 107 | Reads the env files at the provided `env_path` path(s), exports the values into 108 | the system environment, and returns them in a `Dotenv.Env` struct. 109 | """ 110 | def load!(env_path \\ :automatic) do 111 | env = load(env_path) 112 | System.put_env(env.values) 113 | env 114 | end 115 | 116 | @doc """ 117 | Reads the env files at the provided `env_path` path(s) and returns the values 118 | in a `Dotenv.Env` struct. 119 | """ 120 | @spec load(String.t() | :automatic | [String.t()]) :: Env.t() 121 | def load(env_path \\ :automatic) 122 | 123 | def load([env_path | env_paths]) do 124 | first_env = load(env_path) 125 | rest_env = load(env_paths) 126 | 127 | %Env{paths: [env_path | rest_env.paths], values: Map.merge(first_env.values, rest_env.values)} 128 | end 129 | 130 | def load([]) do 131 | %Env{paths: [], values: Map.new()} 132 | end 133 | 134 | def load(env_path) do 135 | {env_path, contents} = read_env_file(env_path) 136 | values = contents |> parse_contents() 137 | %Env{paths: [env_path], values: values} 138 | end 139 | 140 | def parse_contents(contents) do 141 | values = String.split(contents, "\n") 142 | 143 | values 144 | |> Enum.flat_map(&Regex.scan(@pattern, &1)) 145 | |> trim_quotes_from_values 146 | |> Enum.reduce([], &expand_env/2) 147 | |> Enum.reduce(Map.new(), &collect_into_map/2) 148 | end 149 | 150 | defp collect_into_map([_whole, k, v], env), do: Map.put(env, k, v) 151 | defp collect_into_map([_whole, _k], env), do: env 152 | 153 | defp trim_quotes_from_values(values) do 154 | values 155 | |> Enum.map(fn values -> 156 | Enum.map(values, &trim_quotes/1) 157 | end) 158 | end 159 | 160 | defp trim_quotes(value) do 161 | String.replace(value, @quotes_pattern, "\\2") 162 | end 163 | 164 | # without value 165 | defp expand_env([_whole, _k], acc), do: acc 166 | 167 | defp expand_env([whole, k, v], acc) do 168 | matchs = Regex.scan(@env_expand_pattern, v) 169 | 170 | new_value = 171 | case Enum.empty?(matchs) do 172 | true -> 173 | v 174 | 175 | false -> 176 | matchs 177 | |> Enum.reduce(v, fn [_whole, pattern | keys], v -> 178 | v |> replace_env(pattern, keys, acc) 179 | end) 180 | end 181 | 182 | acc ++ [[whole, k, new_value]] 183 | end 184 | 185 | defp replace_env(value, pattern, ["" | keys], env), do: replace_env(value, pattern, keys, env) 186 | defp replace_env(value, pattern, [key | _], env), do: replace_env(value, pattern, key, env) 187 | 188 | defp replace_env(value, pattern, key, %Env{} = env) do 189 | new_value = env |> Env.get(key) || "" 190 | 191 | pattern 192 | |> Regex.escape() 193 | |> Regex.compile!() 194 | |> Regex.replace(value, new_value) 195 | end 196 | 197 | defp replace_env(value, pattern, key, acc) when is_list(acc) do 198 | values = acc |> Enum.reduce(Map.new(), &collect_into_map/2) 199 | replace_env(value, pattern, key, %Env{values: values}) 200 | end 201 | 202 | defp replace_env(value, pattern, key, %{} = values) do 203 | replace_env(value, pattern, key, %Env{values: values}) 204 | end 205 | 206 | defp read_env_file(:automatic) do 207 | case find_env_path() do 208 | {:ok, env_path} -> {env_path, File.read!(env_path)} 209 | {:error, _} -> {:none, ""} 210 | end 211 | end 212 | 213 | defp read_env_file(:none) do 214 | {:none, ""} 215 | end 216 | 217 | defp read_env_file(env_path) do 218 | {env_path, File.read!(env_path)} 219 | end 220 | 221 | defp find_env_path do 222 | find_env_path(File.cwd!()) 223 | end 224 | 225 | defp find_env_path(dir) do 226 | candidate = Path.join(dir, ".env") 227 | 228 | cond do 229 | File.exists?(candidate) -> {:ok, candidate} 230 | dir == "/" -> {:error, "No .env found"} 231 | true -> find_env_path(Path.dirname(dir)) 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/dotenv/env.ex: -------------------------------------------------------------------------------- 1 | defmodule Dotenv.Env do 2 | @type t :: %Dotenv.Env{paths: [String.t()], values: %{String.t() => String.t()}} 3 | defstruct paths: [], values: Map.new() 4 | 5 | def path(%Dotenv.Env{paths: paths}) do 6 | Enum.join(paths, ":") 7 | end 8 | 9 | def get(env, key) do 10 | Dotenv.Env.get(env, System.get_env(key), key) 11 | end 12 | 13 | def get(%Dotenv.Env{values: values}, fallback, key) when is_function(fallback) do 14 | Map.get(values, key, fallback.(key)) 15 | end 16 | 17 | def get(%Dotenv.Env{values: values}, fallback, key) do 18 | Map.get(values, key, fallback) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/dotenv/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Dotenv.Server do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def start_link(env_path) do 6 | GenServer.start_link(__MODULE__, env_path, name: :dotenv) 7 | end 8 | 9 | def init(env_path) do 10 | env = Dotenv.load!(env_path) 11 | {:ok, env} 12 | end 13 | 14 | def handle_cast(:reload!, env) do 15 | {:noreply, Dotenv.load!(env.paths)} 16 | end 17 | 18 | def handle_cast({:reload!, env_path}, _env) do 19 | {:noreply, Dotenv.load!(env_path)} 20 | end 21 | 22 | def handle_call(:env, _from, env) do 23 | {:reply, env, env} 24 | end 25 | 26 | def handle_call({:get, key, fallback}, _from, env) do 27 | {:reply, Dotenv.Env.get(env, fallback, key), env} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/dotenv/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Dotenv.Supervisor do 2 | @moduledoc false 3 | use Supervisor 4 | 5 | def start_link(env_path \\ :automatic) do 6 | Supervisor.start_link(__MODULE__, env_path) 7 | end 8 | 9 | def init(env_path) do 10 | children = [Supervisor.child_spec({Dotenv.Server, env_path}, [])] 11 | Supervisor.init(children, strategy: :one_for_one) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DotenvElixir.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/avdi/dotenv_elixir" 5 | @version "3.1.0" 6 | 7 | def project do 8 | [ 9 | app: :dotenv, 10 | version: @version, 11 | elixir: "~> 1.0", 12 | deps: deps(), 13 | docs: docs(), 14 | package: package() 15 | ] 16 | end 17 | 18 | # Configuration for the OTP application 19 | def application do 20 | [ 21 | mod: {Dotenv, [:automatic]} 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 28 | ] 29 | end 30 | 31 | defp docs do 32 | [ 33 | extras: [ 34 | "LICENSE.md": [title: "License"], 35 | "README.md": [title: "Overview"] 36 | ], 37 | main: "readme", 38 | source_url: @source_url, 39 | source_ref: "v#{@version}", 40 | formatters: ["html"] 41 | ] 42 | end 43 | 44 | defp package do 45 | [ 46 | description: "A port of dotenv to Elixir", 47 | maintainers: ["Jared Norman"], 48 | contributors: [ 49 | "Avdi Grimm", 50 | "David Rouchy", 51 | "Jared Norman", 52 | "Louis Simoneau", 53 | "Michael Bianco" 54 | ], 55 | licenses: ["MIT"], 56 | links: %{ 57 | GitHub: @source_url 58 | } 59 | ] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/dotenv_app_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DotenvAppTest do 2 | use ExUnit.Case 3 | 4 | def fixture_dir, do: Path.expand("../fixture", __ENV__.file()) 5 | def proj1_dir, do: Path.join(fixture_dir(), "proj1") 6 | def root_dir, do: Path.expand("../..", __ENV__.file()) 7 | 8 | setup do 9 | Dotenv.reload!(Path.join(root_dir(), ".env")) 10 | 11 | on_exit(fn -> 12 | System.put_env("APP_TEST_VAR", "") 13 | System.put_env("FOO_BAR", "") 14 | System.put_env("MISSING", "") 15 | end) 16 | end 17 | 18 | test "reloading from a new file" do 19 | Dotenv.reload!(Path.join(proj1_dir(), ".env")) 20 | assert Dotenv.get("FOO_BAR") == "1234" 21 | assert System.get_env("FOO_BAR") == "1234" 22 | end 23 | 24 | test "fetching the whole environment" do 25 | env = Dotenv.env() 26 | 27 | # no need to expand 28 | assert Map.get(env.values, "APP_TEST_VAR") == "HELLO" 29 | 30 | # expand 31 | 32 | assert Map.get(env.values, "EXPAND_VALUE_1") == "HELLO" 33 | assert Map.get(env.values, "EXPAND_VALUE_2") == "TEST_HELLO" 34 | assert Map.get(env.values, "EXPAND_VALUE_3") == "TEST_" 35 | assert Map.get(env.values, "EXPAND_VALUE_4") == "TEST_HELLO_TEST" 36 | assert Map.get(env.values, "EXPAND_VALUE_5") == "TEST_HELLO_TEST" 37 | assert Map.get(env.values, "EXPAND_VALUE_6") == "TEST_HELLO_TEST_HELLO" 38 | assert Map.get(env.values, "EXPAND_VALUE_7") == "TEST_HELLO_TEST_" 39 | 40 | # no expand 41 | assert Map.get(env.values, "SKIP_EXPAND_VALUE_REPLACE_1") == "TEST_\\$APP_TEST_VAR" 42 | assert Map.get(env.values, "SKIP_EXPAND_VALUE_REPLACE_2") == "TEST_\\${APP_TEST_VAR}" 43 | assert Map.get(env.values, "SKIP_EXPAND_VALUE_REPLACE_3") == "TEST_\\${APP_TEST_VAR}_TEST" 44 | assert Map.get(env.values, "SKIP_EXPAND_VALUE_REPLACE_4") == "TEST_\\${APP_TEST_VAR}_TEST" 45 | end 46 | 47 | test "getting a value with a fallback" do 48 | assert Dotenv.get("APP_TEST_VAR", :fallback) == "HELLO" 49 | assert Dotenv.get("MISSING", :fallback) == :fallback 50 | assert Dotenv.get("MISSING", fn _ -> :generated_fallback end) == :generated_fallback 51 | end 52 | 53 | test "fetching a var" do 54 | assert Dotenv.get("APP_TEST_VAR") == "HELLO" 55 | assert System.get_env("APP_TEST_VAR") == "HELLO" 56 | end 57 | 58 | test "should fallback expanded" do 59 | assert Dotenv.get("EXPAND_VALUE_6") == "TEST_HELLO_TEST_HELLO" 60 | assert Dotenv.get("SKIP_EXPAND_VALUE_REPLACE_1") == "TEST_\\$APP_TEST_VAR" 61 | 62 | assert System.get_env("EXPAND_VALUE_6") == "TEST_HELLO_TEST_HELLO" 63 | assert System.get_env("SKIP_EXPAND_VALUE_REPLACE_1") == "TEST_\\$APP_TEST_VAR" 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/dotenv_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DotenvTest do 2 | use ExUnit.Case, async: true 3 | 4 | def fixture_dir, do: Path.expand("../fixture", __ENV__.file()) 5 | def proj1_dir, do: Path.join(fixture_dir(), "proj1") 6 | def proj2_dir, do: Path.join(fixture_dir(), "proj2") 7 | def proj3_dir, do: Path.join(fixture_dir(), "proj3") 8 | 9 | test "parsing a simple dotenv file" do 10 | File.cd!(proj1_dir()) 11 | env = Dotenv.load() 12 | assert Dotenv.Env.path(env) == Path.expand(".env", proj1_dir()) 13 | assert Dotenv.Env.get(env, "FOO_BAR") == "1234" 14 | assert Dotenv.Env.get(env, "BAZ") == "5678" 15 | assert Dotenv.Env.get(env, "BUZ") == "9999" 16 | assert Dotenv.Env.get(env, "QUX") == "0000" 17 | end 18 | 19 | test "parsing a simple dotenv file as found in the real world" do 20 | File.cd!(proj3_dir()) 21 | env = Dotenv.load() 22 | assert Dotenv.Env.get(env, "EMPTY") == nil 23 | assert Dotenv.Env.path(env) == Path.expand(".env", proj3_dir()) 24 | assert Dotenv.Env.get(env, "EXTERNAL_HOST") == "external.example.com" 25 | assert Dotenv.Env.get(env, "EXTERNAL_PROTOCOL") == "https" 26 | assert Dotenv.Env.get(env, "INTERNAL_HOST") == "internal.example.com" 27 | assert Dotenv.Env.get(env, "INTERNAL_PROTOCOL") == "https" 28 | end 29 | 30 | test "it parses values in double quotes" do 31 | File.cd!(proj1_dir()) 32 | env = Dotenv.load() 33 | assert Dotenv.Env.get(env, "DOUBLE_QUOTED_VALUE") == "NoDoubleQuotes" 34 | end 35 | 36 | test "it parses values in single quotes" do 37 | File.cd!(proj1_dir()) 38 | env = Dotenv.load() 39 | assert Dotenv.Env.get(env, "SINGLE_QUOTED_VALUE") == "NoSingleQuotes" 40 | end 41 | 42 | test "finding the dotenv from a subdir" do 43 | File.cd!(Path.join(proj1_dir(), "subdir")) 44 | env = Dotenv.load() 45 | assert Dotenv.Env.path(env) == Path.expand(".env", proj1_dir()) 46 | assert Dotenv.Env.get(env, "FOO_BAR") == "1234" 47 | assert Dotenv.Env.get(env, "BAZ") == "5678" 48 | assert Dotenv.Env.get(env, "BUZ") == "9999" 49 | assert Dotenv.Env.get(env, "QUX") == "0000" 50 | end 51 | 52 | test "loading into system environment" do 53 | import System, only: [get_env: 1] 54 | File.cd!(Path.join(proj1_dir(), "subdir")) 55 | Dotenv.load!() 56 | assert get_env("FOO_BAR") == "1234" 57 | assert get_env("BAZ") == "5678" 58 | assert get_env("BUZ") == "9999" 59 | assert get_env("QUX") == "0000" 60 | end 61 | 62 | test "falling back to system environment" do 63 | File.cd!(proj1_dir()) 64 | System.put_env("FOO_BAR", "ORIGINAL_FOO_BAR") 65 | System.put_env("BAZZLE", "4321") 66 | env = Dotenv.load() 67 | assert Dotenv.Env.path(env) == Path.expand(".env", proj1_dir()) 68 | # .env values take precedence 69 | assert Dotenv.Env.get(env, "FOO_BAR") == "1234" 70 | assert Dotenv.Env.get(env, "BAZZLE") == "4321" 71 | end 72 | 73 | test "with explicit file" do 74 | env_path = Path.join(proj2_dir(), ".env") 75 | env = Dotenv.load!(env_path) 76 | assert Dotenv.Env.path(env) == env_path 77 | assert System.get_env("PROJ2_VAR") == "9876" 78 | end 79 | 80 | test "with multiple explicit files" do 81 | env_paths = [Path.join(proj1_dir(), ".env"), Path.join(proj2_dir(), ".env")] 82 | env = Dotenv.load!(env_paths) 83 | assert Dotenv.Env.path(env) == env_paths |> Enum.join(":") 84 | assert System.get_env("PROJ2_VAR") == "9876" 85 | assert System.get_env("FOO_BAR") == "PROJ2_FOO_BAR" 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/fixture/proj1/.env: -------------------------------------------------------------------------------- 1 | export FOO_BAR=1234 2 | BAZ = 5678 3 | # this is a comment 4 | BUZ: 9999 5 | QUX=0000 # this is another comment 6 | DOUBLE_QUOTED_VALUE="NoDoubleQuotes" 7 | SINGLE_QUOTED_VALUE='NoSingleQuotes' 8 | -------------------------------------------------------------------------------- /test/fixture/proj1/subdir/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avdi/dotenv_elixir/d6fd3f327173fe18a455203987da95ef9f6cd4c5/test/fixture/proj1/subdir/.gitkeep -------------------------------------------------------------------------------- /test/fixture/proj2/.env: -------------------------------------------------------------------------------- 1 | FOO_BAR=PROJ2_FOO_BAR 2 | PROJ2_VAR=9876 3 | -------------------------------------------------------------------------------- /test/fixture/proj3/.env: -------------------------------------------------------------------------------- 1 | # This file is auto generated. Do not alter !!! 2 | # Source: Consul 3 | EMPTY= 4 | export EXTERNAL_HOST='external.example.com' 5 | export EXTERNAL_PORT= 6 | export EXTERNAL_PROTOCOL='https' 7 | export INTERNAL_HOST='internal.example.com' 8 | export INTERNAL_PORT= 9 | export INTERNAL_PROTOCOL='https' 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------