diff --git a/Cargo.lock b/Cargo.lock index 24fec1f..07600e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "envkit" -version = "0.2.0" +version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 56d8ea9..ce32049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "envkit" -version = "0.2.0" +version = "0.2.1" edition = "2024" authors = ["Darwin Cereska "] description = "A dead-simple env loader" diff --git a/README.md b/README.md index 22dfbe6..df12c6d 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,174 @@ # 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. -- `EnvLoader::get_or` reads a variable with a fallback. -- `EnvLoader::get_opt` returns `Option`. -- `with_prefix` lets you build loaders for namespaced variables like `APP_PORT` or `DB_HOST`. +## Install -## 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 = 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` - `bool` -- numeric types: `i8`, `i16`, `i32`, `i64`, `i128`, `isize`, `u8`, `u16`, `u32`, `u64`, `u128`, `usize`, `f32`, `f64` -- Vectors with any of the supported types +- signed integers: `i8`, `i16`, `i32`, `i64`, `i128`, `isize` +- unsigned integers: `u8`, `u16`, `u32`, `u64`, `u128`, `usize` +- floats: `f32`, `f64` +- `Vec` where `T` also implements `FromEnv` -Boolean parsing accepts: +Boolean values accept: - `true` / `false` - `1` / `0` - `t` / `f` - `yes` / `no` -## Quick start +Lists are comma-separated and each item is trimmed before parsing: ```rust -use envkit::EnvLoader; - -fn main() -> Result<(), envkit::error::EnvError> { - 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(()) -} +let env = envkit::EnvLoader::new(); +let ports: Vec = env.get("PORTS")?; +let flags: Vec = env.get("FEATURE_FLAGS")?; ``` -## Prefixed variables +## Custom Types + +Implement `FromEnv` for your own types when you want domain-specific parsing. ```rust -use envkit::EnvLoader; +use envkit::{error::EnvError, FromEnv}; -fn main() -> Result<(), envkit::error::EnvError> { - let loader = EnvLoader; - let app = loader.with_prefix("APP_"); +enum LogFormat { + Json, + Pretty, +} - let host: String = app.get("HOST")?; - let port: u16 = app.get_or("PORT", 8080); - - println!("host={host}, port={port}"); - Ok(()) +impl FromEnv for LogFormat { + fn from_env(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "json" => Ok(Self::Json), + "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 -`envkit` returns `EnvError` for two cases: +`envkit` returns `EnvError` values: -- `MissingVar` when the environment variable is not present -- `Invalid` when parsing fails +- `EnvError::MissingVar(key)` when a required environment variable is not set. +- `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 -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 diff --git a/examples/basic.rs b/examples/basic.rs index f322f19..9826e3e 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,15 +1,26 @@ use envkit::EnvLoader; 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 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 = loader.get("ALLOWED_PORTS")?; - println!("port={port}"); - println!("debug={debug}"); - println!("app_name={app_name}"); + println!("{app_name}"); + println!("port: {port}"); + println!("debug: {debug}"); + println!("allowed ports: {allowed_ports:?}"); Ok(()) } diff --git a/examples/prefixed.rs b/examples/prefixed.rs index 3c671dc..57be529 100644 --- a/examples/prefixed.rs +++ b/examples/prefixed.rs @@ -1,16 +1,22 @@ use envkit::EnvLoader; 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 host: String = app.get("HOST")?; let port: u16 = app.get_or("PORT", 8080); let enabled: bool = app.get_or("ENABLED", false); - println!("host={host}"); - println!("port={port}"); - println!("enabled={enabled}"); + println!("host: {host}"); + println!("port: {port}"); + println!("enabled: {enabled}"); Ok(()) } diff --git a/src/error.rs b/src/error.rs index 3b00d47..5de060d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,9 @@ pub enum EnvError { /// An environment variable was expected, but it was not found. 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 { @@ -28,4 +31,12 @@ impl EnvError { where T: Into { Self::MissingVar(key.into()) } + + /// Creates a new `EnvError::FileMissing` with the given path and message. + pub fn file_error, M: Into>(path: P, message: M) -> Self { + Self::FileError { + path: path.into(), + message: message.into() + } + } } diff --git a/src/lib.rs b/src/lib.rs index fe793b0..2928e47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,35 @@ impl FromEnv for String { pub struct 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. pub fn with_prefix>(&self, prefix: P) -> PrefixedEnvLoader { PrefixedEnvLoader { diff --git a/tests/test_main.rs b/tests/test_main.rs index 8eef4bb..a5dc60b 100644 --- a/tests/test_main.rs +++ b/tests/test_main.rs @@ -1,43 +1,160 @@ -#[cfg(test)] -mod test_main { - use envkit::*; +use envkit::{EnvLoader, error::EnvError}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; - #[test] - /// Tests that `get` successfully retrieves an existing environment variable. - fn test_get() { - let env = EnvLoader; - - unsafe { - std::env::set_var("TEST_VAR", "test_value"); - } - - assert_eq!(env.get::("TEST_VAR"), Ok("test_value".to_string())); +fn unique_key(name: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after the Unix epoch") + .as_nanos(); + + format!("ENVKIT_TEST_{name}_{nanos}") +} + +#[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] - /// Tests that `get_or` returns the default value when the environment variable is missing. - fn test_get_or() { - let env = EnvLoader; - - unsafe { - std::env::remove_var("MISSING_VAR"); - std::env::set_var("TEST_VAR", "test_value"); - } - - assert_eq!(env.get_or::("MISSING_VAR", "default".to_string()), "default".to_string()); - assert_eq!(env.get_or::("TEST_VAR", "default".to_string()), "test_value".to_string()); + assert_eq!(env.get::(&name_key), Ok("envkit".to_string())); + assert_eq!(env.get::(&port_key), Ok(3000)); + assert_eq!(env.get::(&debug_key), Ok(true)); + assert_eq!(env.get::>(&list_key), Ok(vec![3000, 3001, 3002])); + + unsafe { + std::env::remove_var(name_key); + std::env::remove_var(port_key); + std::env::remove_var(debug_key); + std::env::remove_var(list_key); + } +} + +#[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] - /// Tests that `get_opt` returns `Some` when the environment variable exists. - fn test_get_opt() { - let env = EnvLoader; - - unsafe { - std::env::set_var("OPTIONAL_VAR", "optional_value"); - } + assert_eq!( + env.get::(&missing_key), + Err(EnvError::MissingVar(missing_key.clone())) + ); - assert_eq!(env.get_opt::("OPTIONAL_VAR"), Some("optional_value".to_string())); - assert_eq!(env.get_opt::("DEV"), None); + assert!(matches!( + env.get::(&invalid_key), + Err(EnvError::Invalid { key, value, .. }) + if key == "u16" && value == "not-a-port" + )); + + unsafe { + std::env::remove_var(invalid_key); } -} \ No newline at end of file +} + +#[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::(&present_key, 7), 42); + assert_eq!(env.get_or::(&missing_key, 7), 7); + assert_eq!(env.get_or::(&invalid_key, false), false); + + assert_eq!(env.get_opt::(&present_key), Some(42)); + assert_eq!(env.get_opt::(&missing_key), None); + assert_eq!(env.get_opt::(&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::("HOST"), Ok("127.0.0.1".to_string())); + assert_eq!(app.get::("ENABLED"), Ok(true)); + assert_eq!(app.get_or::("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::(&port_key), Ok(8080)); + assert_eq!(env.get::(&debug_key), Ok(true)); + assert_eq!( + env.get::>(&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 +}