README.md

<p align="center">
  <img src="https://raw.githubusercontent.com/lupodevelop/envie/91a8dd6/assets/img/envie.png" alt="envie logo" width="200" />
</p>

[![Package Version](https://img.shields.io/hexpm/v/envie)](https://hex.pm/packages/envie)[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/envie/)[![Built with Gleam](https://img.shields.io/badge/built%20with-gleam-ffaff3?logo=gleam)](https://gleam.run)[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

# envie

Type-safe environment variables for Gleam.
Zero external dependencies, cross-platform (Erlang + JavaScript).

> Why the name? `envy` was already taken on Hex, so we went with `envie`. 
> Because you shouldn't be jealous of other languages' config loaders — you should just *desire* (French: *envie*) a better one. Plus, it makes your environment variables feel 20% more sophisticated. 🥐

envie gets out of your way: import it, call `get(...)`, and you're done.
When you need more typed parsing, validation, `.env` loading, test isolation,
it's all there without changing the core workflow.

## Quick start

```sh
gleam add envie
```

```gleam
import envie

pub fn main() {
  let port = envie.get_int("PORT", 3000)
  let debug = envie.get_bool("DEBUG", False)

  // That's it. No setup, no ceremony.
}
```

## Core API

Read, write, and remove environment variables on any target.

```gleam
envie.get("HOME")           // -> Ok("/Users/you")
envie.set("APP_ENV", "production")
envie.unset("TEMP_TOKEN")
let all = envie.all()       // -> Dict(String, String)
```

## Type-safe getters

Parse common types without boilerplate.
When the variable is missing or the value cannot be parsed,
you get the default back.

```gleam
let port    = envie.get_int("PORT", 3000)
let ratio   = envie.get_float("RATIO", 1.0)
let debug   = envie.get_bool("DEBUG", False)
let origins = envie.get_string_list("ORIGINS", separator: ",", default: [])
```

Boolean parsing accepts (case-insensitive):

| Truthy         | Falsy          |
|----------------|----------------|
| `true` `yes` `1` `on` | `false` `no` `0` `off` |

## Validated access

When a missing or malformed variable should be a hard error,
use `require_*`. Every function returns `Result(value, Error)`
with a structured error you can format for logs.

No extra imports — just `envie`:

```gleam
import envie

let assert Ok(key)    = envie.require_string("API_KEY")
let assert Ok(port)   = envie.require_port("PORT") // 1–65 535
let assert Ok(url)    = envie.require_web_url("DATABASE_URL") // requires http/https
let assert Ok(name)   = envie.require_non_empty_string("APP_NAME")
let assert Ok(on)     = envie.require_bool("DEBUG")
let assert Ok(ratio)  = envie.require_float_range("RATIO", min: 0.0, max: 1.0)
let assert Ok(env)    = envie.require_one_of("APP_ENV", ["development", "staging", "production"])
```

## Custom validation

If none of the built-in `require_*` functions covers your case,
import `envie/decode` and compose a custom decoder:

```gleam
import envie
import envie/decode
import gleam/string

let secret_decoder =
  decode.string()
  |> decode.validated(fn(s) {
    case string.length(s) >= 32 {
      True -> Ok(s)
      False -> Error("Secret must be at least 32 characters")
    }
  })

let assert Ok(secret) = envie.require("JWT_SECRET", secret_decoder)
```

This is the only scenario where you need `envie/decode`.

## Optional variables

```gleam
let assert Ok(maybe_port) = envie.optional("METRICS_PORT", decode.int())
// Ok(None) when missing, Ok(Some(value)) when present and valid
```

## `.env` file loading

Supports comments (`#`), inline comments (`PORT=8080 # default`),
blank lines, `export` prefix, and single/double-quoted values.

By default, existing environment variables are **not** overwritten.
Use `load_override` / `load_override_from` when `.env` values should win.

```gleam
let assert Ok(Nil) = envie.load()                          // .env in cwd
let assert Ok(Nil) = envie.load_from("config/.env.local")  // custom path
let assert Ok(Nil) = envie.load_from_string("PORT=8080")   // from string

// Force overwrite
let assert Ok(Nil) = envie.load_override()
```

Example `.env` file:

```env
# Application
PORT=8080
DEBUG=true

# Secrets
export API_KEY="sk-1234567890"
DB_PASSWORD='hunter2'
```

## Testing utilities

Helpers that guarantee the environment is restored after each test.

```gleam
import envie
import envie/testing

pub fn my_feature_test() {
  testing.with_env([#("PORT", "3000"), #("DEBUG", "true")], fn() {
    let port = envie.get_int("PORT", 8080)
    port |> should.equal(3000)
  })
  // Original environment is restored automatically
}

pub fn isolated_test() {
  testing.isolated(fn() {
    envie.get("PATH") |> should.equal(Error(Nil))
  })
  // Everything restored
}
```

> [!WARNING]
> **Test Concurrency**: Environment variables are global to the process. Since Gleam runs tests in parallel by default, multiple tests using `testing.*` concurrently may interfere with each other. Use `gleam test -- --seed 123` (or any seed) to run tests with a single worker if you encounter flaky tests.

## Error formatting

Every error type carries enough context to produce clear messages.

```gleam
case envie.require_int("PORT") {
  Ok(port) -> start_server(port)
  Error(err) -> {
    io.println_error(envie.format_error(err))
    // "PORT: invalid value "abc" — Expected integer, got: abc"
  }
}
```

## API at a glance

| Function                             | Returns                    | Notes                |
|--------------------------------------|----------------------------|----------------------|
| `get`                                | `Result(String, Nil)`      | Raw access           |
| `set`                                | `Nil`                      |                      |
| `unset`                              | `Nil`                      |                      |
| `all`                                | `Dict(String, String)`     |                      |
| `get_string`                         | `String`                   | Falls back to caller-supplied default |
| `get_int`                            | `Int`                      | Falls back to caller-supplied default |
| `get_float`                          | `Float`                    | Falls back to caller-supplied default |
| `get_bool`                           | `Bool`                     | Falls back to default; true/yes/1/on  |
| `get_string_list`                    | `List(String)`             | Falls back to default; splits & trims |
| `require`                            | `Result(a, Error)`         | Decoder-based        |
| `require_string`                     | `Result(String, Error)`    |                      |
| `require_int`                        | `Result(Int, Error)`       |                      |
| `require_int_range`                  | `Result(Int, Error)`       |                      |
| `require_float`                      | `Result(Float, Error)`     |                      |
| `require_float_range`                | `Result(Float, Error)`     |                      |
| `require_url`                        | `Result(Uri, Error)`       | Permissive RFC parse |
| `require_url_with_scheme`            | `Result(Uri, Error)`       | e.g. ["postgres"]    |
| `require_web_url`                    | `Result(Uri, Error)`       | http or https only   |
| `require_non_empty_string`           | `Result(String, Error)`    |                      |
| `require_string_prefix`              | `Result(String, Error)`    |                      |
| `require_string_list`                | `Result(List(String), Error)` |                   |
| `require_int_list`                   | `Result(List(Int), Error)` |                      |
| `require_bool`                       | `Result(Bool, Error)`      | true/yes/1/on        |
| `require_port`                       | `Result(Int, Error)`       | 1–65 535             |
| `require_one_of`                     | `Result(String, Error)`    | Allow-list check     |
| `optional`                           | `Result(Option(a), Error)` |                      |
| `load`                               | `Result(Nil, LoadError)`   | `.env` in cwd        |
| `load_from`                          | `Result(Nil, LoadError)`   | Custom path          |
| `load_from_string`                   | `Result(Nil, LoadError)`   | From string          |
| `load_override`                      | `Result(Nil, LoadError)`   | Overwrites env       |
| `load_override_from`                 | `Result(Nil, LoadError)`   | Overwrites env       |
| `load_from_string_override`          | `Result(Nil, LoadError)`   | Overwrites env       |

## Cross-platform

---

envie works on Erlang and all major JavaScript runtimes.

- **Erlang/OTP** — uses `os:getenv/1`, `os:putenv/2`, `os:unsetenv/1`.
- **JavaScript (Node.js, Bun)** — uses `process.env` and `fs` module.
- **JavaScript (Deno)** — uses `Deno.env` and `Deno.readTextFileSync`.
- **JavaScript (Browser)** — environment variables and `.env` loading are safely disabled (returning `Error`) without crashing your build or runtime.

## Dependencies & Requirements

---

* Gleam **1.14** or newer.
* OTP 27+ on the BEAM.
* Just `gleam_stdlib` — no runtime dependencies.

---

<p align="center">Made with Gleam 💜</p>