2 Commits

Author SHA1 Message Date
darwincereska 36c74e0c25 feat: added file loader 2026-06-08 12:38:41 -04:00
darwincereska 56339890e5 docs: updated readme 2026-06-08 12:09:43 -04:00
8 changed files with 360 additions and 86 deletions
Generated
+1 -1
View File
@@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "envkit" name = "envkit"
version = "0.2.0" version = "0.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "envkit" name = "envkit"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
authors = ["Darwin Cereska <discorddurr@gmail.com>"] authors = ["Darwin Cereska <discorddurr@gmail.com>"]
description = "A dead-simple env loader" description = "A dead-simple env loader"
+139 -39
View File
@@ -1,74 +1,174 @@
# envkit # envkit
`envkit` is a small Rust crate for reading environment variables and converting them into typed values. `envkit` is a small Rust crate for reading environment variables as typed values.
It keeps the API minimal: It provides a minimal loader API for required values, optional values, defaults,
prefixed variable names, and simple `.env`-style files.
- `EnvLoader::get` reads a required variable. ## Install
- `EnvLoader::get_or` reads a variable with a fallback.
- `EnvLoader::get_opt` returns `Option<T>`.
- `with_prefix` lets you build loaders for namespaced variables like `APP_PORT` or `DB_HOST`.
## Supported types Add `envkit` to your `Cargo.toml`:
`envkit` currently supports: ```toml
[dependencies]
envkit = "0.2.1"
```
## Quick Start
```rust
use envkit::EnvLoader;
fn main() -> Result<(), envkit::error::EnvError> {
let env = EnvLoader::new();
let port: u16 = env.get("PORT")?;
let debug: bool = env.get_or("DEBUG", false);
let app_name: String = env
.get_opt("APP_NAME")
.unwrap_or_else(|| "envkit".to_string());
println!("{app_name} listening on port {port}; debug={debug}");
Ok(())
}
```
## API
### `EnvLoader::get`
Reads a required environment variable and parses it into the requested type.
```rust
let env = envkit::EnvLoader::new();
let port: u16 = env.get("PORT")?;
```
Returns `EnvError::MissingVar` when the variable is not set and
`EnvError::Invalid` when parsing fails.
### `EnvLoader::get_or`
Reads an environment variable and returns a fallback when the variable is
missing or invalid.
```rust
let debug: bool = env.get_or("DEBUG", false);
```
### `EnvLoader::get_opt`
Reads an environment variable and returns `None` when the variable is missing or
invalid.
```rust
let name: Option<String> = env.get_opt("APP_NAME");
```
### `EnvLoader::with_prefix`
Creates a loader that prepends a prefix to every key.
```rust
let app = env.with_prefix("APP_");
let host: String = app.get("HOST")?; // reads APP_HOST
let port: u16 = app.get_or("PORT", 8080); // reads APP_PORT
```
### `EnvLoader::load_file`
Loads variables from a file with one `KEY=VALUE` pair per line.
```rust
let env = envkit::EnvLoader::new();
env.load_file(".env")?;
```
Blank lines and lines starting with `#` are ignored. Keys and values are
trimmed before being written into the process environment.
Example file:
```text
# .env
PORT=8080
DEBUG=true
APP_NAME=envkit
```
## Supported Types
Built-in parsing supports:
- `String` - `String`
- `bool` - `bool`
- numeric types: `i8`, `i16`, `i32`, `i64`, `i128`, `isize`, `u8`, `u16`, `u32`, `u64`, `u128`, `usize`, `f32`, `f64` - signed integers: `i8`, `i16`, `i32`, `i64`, `i128`, `isize`
- unsigned integers: `u8`, `u16`, `u32`, `u64`, `u128`, `usize`
- floats: `f32`, `f64`
- `Vec<T>` where `T` also implements `FromEnv`
Boolean parsing accepts: Boolean values accept:
- `true` / `false` - `true` / `false`
- `1` / `0` - `1` / `0`
- `t` / `f` - `t` / `f`
- `yes` / `no`
## Quick start Lists are comma-separated and each item is trimmed before parsing:
```rust ```rust
use envkit::EnvLoader; let env = envkit::EnvLoader::new();
let ports: Vec<u16> = env.get("PORTS")?;
fn main() -> Result<(), envkit::error::EnvError> { let flags: Vec<bool> = env.get("FEATURE_FLAGS")?;
let loader = EnvLoader;
let port: u16 = loader.get("PORT")?;
let debug: bool = loader.get_or("DEBUG", false);
let app_name: String = loader.get_opt("APP_NAME").unwrap_or_else(|| "envkit".to_string());
println!("port={port}, debug={debug}, app_name={app_name}");
Ok(())
}
``` ```
## Prefixed variables ## Custom Types
Implement `FromEnv` for your own types when you want domain-specific parsing.
```rust ```rust
use envkit::EnvLoader; use envkit::{error::EnvError, FromEnv};
fn main() -> Result<(), envkit::error::EnvError> { enum LogFormat {
let loader = EnvLoader; Json,
let app = loader.with_prefix("APP_"); Pretty,
}
let host: String = app.get("HOST")?; impl FromEnv for LogFormat {
let port: u16 = app.get_or("PORT", 8080); fn from_env(value: &str) -> Result<Self, EnvError> {
match value.trim().to_lowercase().as_str() {
println!("host={host}, port={port}"); "json" => Ok(Self::Json),
Ok(()) "pretty" => Ok(Self::Pretty),
other => Err(EnvError::invalid(
"LogFormat",
other,
"expected `json` or `pretty`",
)),
}
}
} }
``` ```
If you use the prefixed loader above, it reads `APP_HOST` and `APP_PORT`.
## Errors ## Errors
`envkit` returns `EnvError` for two cases: `envkit` returns `EnvError` values:
- `MissingVar` when the environment variable is not present - `EnvError::MissingVar(key)` when a required environment variable is not set.
- `Invalid` when parsing fails - `EnvError::Invalid { key, value, message }` when a value cannot be parsed.
- `EnvError::FileError { path, message }` when a file cannot be read or contains
an invalid line.
## Examples ## Examples
See the `examples/` folder for runnable usage samples. Run the included examples with:
```bash
cargo run --example basic
cargo run --example prefixed
```
The examples seed their own environment variables so they can be run directly.
## Development ## Development
+16 -5
View File
@@ -1,15 +1,26 @@
use envkit::EnvLoader; use envkit::EnvLoader;
fn main() -> Result<(), envkit::error::EnvError> { fn main() -> Result<(), envkit::error::EnvError> {
let loader = EnvLoader; unsafe {
std::env::set_var("PORT", "8080");
std::env::set_var("DEBUG", "true");
std::env::set_var("APP_NAME", "envkit");
std::env::set_var("ALLOWED_PORTS", "8080, 8081, 8082");
}
let loader = EnvLoader::new();
let port: u16 = loader.get("PORT")?; let port: u16 = loader.get("PORT")?;
let debug: bool = loader.get_or("DEBUG", false); let debug: bool = loader.get_or("DEBUG", false);
let app_name: String = loader.get_opt("APP_NAME").unwrap_or_else(|| "envkit".to_string()); let app_name: String = loader
.get_opt("APP_NAME")
.unwrap_or_else(|| "envkit".to_string());
let allowed_ports: Vec<u16> = loader.get("ALLOWED_PORTS")?;
println!("port={port}"); println!("{app_name}");
println!("debug={debug}"); println!("port: {port}");
println!("app_name={app_name}"); println!("debug: {debug}");
println!("allowed ports: {allowed_ports:?}");
Ok(()) Ok(())
} }
+10 -4
View File
@@ -1,16 +1,22 @@
use envkit::EnvLoader; use envkit::EnvLoader;
fn main() -> Result<(), envkit::error::EnvError> { fn main() -> Result<(), envkit::error::EnvError> {
let loader = EnvLoader; unsafe {
std::env::set_var("APP_HOST", "127.0.0.1");
std::env::set_var("APP_PORT", "3000");
std::env::set_var("APP_ENABLED", "yes");
}
let loader = EnvLoader::new();
let app = loader.with_prefix("APP_"); let app = loader.with_prefix("APP_");
let host: String = app.get("HOST")?; let host: String = app.get("HOST")?;
let port: u16 = app.get_or("PORT", 8080); let port: u16 = app.get_or("PORT", 8080);
let enabled: bool = app.get_or("ENABLED", false); let enabled: bool = app.get_or("ENABLED", false);
println!("host={host}"); println!("host: {host}");
println!("port={port}"); println!("port: {port}");
println!("enabled={enabled}"); println!("enabled: {enabled}");
Ok(()) Ok(())
} }
+11
View File
@@ -6,6 +6,9 @@ pub enum EnvError {
/// An environment variable was expected, but it was not found. /// An environment variable was expected, but it was not found.
MissingVar(String), MissingVar(String),
/// A file was expected to be found, but it was not found or could not be read.
FileError { path: String, message: String }
} }
impl EnvError { impl EnvError {
@@ -28,4 +31,12 @@ impl EnvError {
where T: Into<String> { where T: Into<String> {
Self::MissingVar(key.into()) Self::MissingVar(key.into())
} }
/// Creates a new `EnvError::FileMissing` with the given path and message.
pub fn file_error<P: Into<String>, M: Into<String>>(path: P, message: M) -> Self {
Self::FileError {
path: path.into(),
message: message.into()
}
}
} }
+29
View File
@@ -49,6 +49,35 @@ impl FromEnv for String {
pub struct EnvLoader; pub struct EnvLoader;
impl EnvLoader { impl EnvLoader {
/// Creates a new `EnvLoader`.
pub fn new() -> Self {
Self
}
/// Loads environment variables from a file. The file should have one variable per line, in the format `KEY=VALUE`. Lines starting with `#` are treated as comments and ignored.
pub fn load_file(&self, path: &str) -> Result<(), EnvError> {
// Load the file
let contents = std::fs::read_to_string(path).map_err(|e| EnvError::file_error(path, e.to_string()))?;
// Parse the file
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("#") { continue }
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(EnvError::file_error(path, format!("Invalid line: {}", line)));
}
// Trim the key and value
let key = parts[0].trim();
let value = parts[1].trim();
// Set the environment variable
unsafe { std::env::set_var(key, value) }
}
Ok(())
}
/// Creates a new `EnvLoader` with the given prefix. /// Creates a new `EnvLoader` with the given prefix.
pub fn with_prefix<P: Into<String>>(&self, prefix: P) -> PrefixedEnvLoader { pub fn with_prefix<P: Into<String>>(&self, prefix: P) -> PrefixedEnvLoader {
PrefixedEnvLoader { PrefixedEnvLoader {
+153 -36
View File
@@ -1,43 +1,160 @@
#[cfg(test)] use envkit::{EnvLoader, error::EnvError};
mod test_main { use std::path::PathBuf;
use envkit::*; use std::time::{SystemTime, UNIX_EPOCH};
#[test] fn unique_key(name: &str) -> String {
/// Tests that `get` successfully retrieves an existing environment variable. let nanos = SystemTime::now()
fn test_get() { .duration_since(UNIX_EPOCH)
let env = EnvLoader; .expect("system time should be after the Unix epoch")
.as_nanos();
unsafe {
std::env::set_var("TEST_VAR", "test_value"); format!("ENVKIT_TEST_{name}_{nanos}")
} }
assert_eq!(env.get::<String>("TEST_VAR"), Ok("test_value".to_string())); #[test]
fn get_reads_required_typed_values() {
let env = EnvLoader::new();
let name_key = unique_key("NAME");
let port_key = unique_key("PORT");
let debug_key = unique_key("DEBUG");
let list_key = unique_key("PORTS");
unsafe {
std::env::set_var(&name_key, "envkit");
std::env::set_var(&port_key, "3000");
std::env::set_var(&debug_key, "yes");
std::env::set_var(&list_key, "3000, 3001, 3002");
} }
#[test] assert_eq!(env.get::<String>(&name_key), Ok("envkit".to_string()));
/// Tests that `get_or` returns the default value when the environment variable is missing. assert_eq!(env.get::<u16>(&port_key), Ok(3000));
fn test_get_or() { assert_eq!(env.get::<bool>(&debug_key), Ok(true));
let env = EnvLoader; assert_eq!(env.get::<Vec<u16>>(&list_key), Ok(vec![3000, 3001, 3002]));
unsafe { unsafe {
std::env::remove_var("MISSING_VAR"); std::env::remove_var(name_key);
std::env::set_var("TEST_VAR", "test_value"); std::env::remove_var(port_key);
} std::env::remove_var(debug_key);
std::env::remove_var(list_key);
assert_eq!(env.get_or::<String>("MISSING_VAR", "default".to_string()), "default".to_string()); }
assert_eq!(env.get_or::<String>("TEST_VAR", "default".to_string()), "test_value".to_string()); }
#[test]
fn get_reports_missing_and_invalid_values() {
let env = EnvLoader::new();
let missing_key = unique_key("MISSING");
let invalid_key = unique_key("INVALID");
unsafe {
std::env::remove_var(&missing_key);
std::env::set_var(&invalid_key, "not-a-port");
} }
#[test] assert_eq!(
/// Tests that `get_opt` returns `Some` when the environment variable exists. env.get::<String>(&missing_key),
fn test_get_opt() { Err(EnvError::MissingVar(missing_key.clone()))
let env = EnvLoader; );
unsafe {
std::env::set_var("OPTIONAL_VAR", "optional_value");
}
assert_eq!(env.get_opt::<String>("OPTIONAL_VAR"), Some("optional_value".to_string())); assert!(matches!(
assert_eq!(env.get_opt::<bool>("DEV"), None); env.get::<u16>(&invalid_key),
Err(EnvError::Invalid { key, value, .. })
if key == "u16" && value == "not-a-port"
));
unsafe {
std::env::remove_var(invalid_key);
} }
} }
#[test]
fn get_or_and_get_opt_handle_fallbacks() {
let env = EnvLoader::new();
let present_key = unique_key("PRESENT");
let missing_key = unique_key("DEFAULT");
let invalid_key = unique_key("BAD_BOOL");
unsafe {
std::env::set_var(&present_key, "42");
std::env::remove_var(&missing_key);
std::env::set_var(&invalid_key, "maybe");
}
assert_eq!(env.get_or::<u8>(&present_key, 7), 42);
assert_eq!(env.get_or::<u8>(&missing_key, 7), 7);
assert_eq!(env.get_or::<bool>(&invalid_key, false), false);
assert_eq!(env.get_opt::<u8>(&present_key), Some(42));
assert_eq!(env.get_opt::<u8>(&missing_key), None);
assert_eq!(env.get_opt::<bool>(&invalid_key), None);
unsafe {
std::env::remove_var(present_key);
std::env::remove_var(invalid_key);
}
}
#[test]
fn prefixed_loader_reads_namespaced_variables() {
let env = EnvLoader::new();
let prefix = unique_key("APP_");
let host_key = format!("{prefix}HOST");
let enabled_key = format!("{prefix}ENABLED");
unsafe {
std::env::set_var(&host_key, "127.0.0.1");
std::env::set_var(&enabled_key, "1");
}
let app = env.with_prefix(prefix);
assert_eq!(app.get::<String>("HOST"), Ok("127.0.0.1".to_string()));
assert_eq!(app.get::<bool>("ENABLED"), Ok(true));
assert_eq!(app.get_or::<u16>("PORT", 8080), 8080);
unsafe {
std::env::remove_var(host_key);
std::env::remove_var(enabled_key);
}
}
#[test]
fn load_file_sets_environment_variables() {
let env = EnvLoader::new();
let prefix = unique_key("FILE_");
let port_key = format!("{prefix}PORT");
let debug_key = format!("{prefix}DEBUG");
let names_key = format!("{prefix}NAMES");
let path = temp_env_file(&prefix, &port_key, &debug_key, &names_key);
env.load_file(path.to_str().expect("test path should be UTF-8"))
.expect("env file should load");
assert_eq!(env.get::<u16>(&port_key), Ok(8080));
assert_eq!(env.get::<bool>(&debug_key), Ok(true));
assert_eq!(
env.get::<Vec<String>>(&names_key),
Ok(vec!["api".to_string(), "worker".to_string()])
);
std::fs::remove_file(path).expect("test env file should be removable");
unsafe {
std::env::remove_var(port_key);
std::env::remove_var(debug_key);
std::env::remove_var(names_key);
}
}
fn temp_env_file(prefix: &str, port_key: &str, debug_key: &str, names_key: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("{prefix}.env"));
let contents = format!(
r#"
# comments and blank lines are ignored
{port_key} = 8080
{debug_key}= yes
{names_key}= api, worker
"#
);
std::fs::write(&path, contents).expect("test env file should be writable");
path
}