diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index b9e42128..14bf75fc 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -1,4 +1,5 @@ use crate::mode::Mode; +use crate::route::Route; use glob::glob; use glob::GlobError; use http::Method; @@ -16,8 +17,6 @@ use std::process::Stdio; use tracing::error; use tuono_internal::config::Config; -use crate::route::Route; - const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"]; const IGNORE_FILES: [&str; 1] = ["__layout"]; diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 2244edad..3f1e396b 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -124,7 +124,7 @@ fn create_modules_declaration(routes: &HashMap) -> String { } pub fn bundle_axum_source(mode: Mode) -> io::Result { - let base_path = std::env::current_dir().unwrap(); + let base_path = std::env::current_dir()?; let app = App::new(); let bundled_file = generate_axum_source(&app, mode); @@ -213,10 +213,11 @@ mod tests { #[test] fn should_set_the_correct_mode() { let source_builder = App::new(); - let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); + assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;")); + let source_builder = App::new(); let prod_bundle = generate_axum_source(&source_builder, Mode::Prod); assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;")); diff --git a/crates/tuono/src/watch.rs b/crates/tuono/src/watch.rs index 59e45db7..f3360ee2 100644 --- a/crates/tuono/src/watch.rs +++ b/crates/tuono/src/watch.rs @@ -1,3 +1,6 @@ +use std::borrow::Cow; +use std::ffi::OsStr; +use std::fs; use std::path::Path; use std::sync::Arc; use watchexec_supervisor::command::{Command, Program}; @@ -74,6 +77,14 @@ fn build_react_ssr_src() -> Job { .0 } +fn ssr_reload_needed(file_name: Option<&OsStr>, file_path: Cow) -> bool { + file_name + .map(|f| f.to_string_lossy().starts_with(".env")) + .unwrap_or(false) + || file_path.ends_with("sx") + || file_path.ends_with("mdx") +} + #[tokio::main] pub async fn watch() -> Result<()> { let term = Term::stdout(); @@ -86,6 +97,13 @@ pub async fn watch() -> Result<()> { let build_ssr_bundle = build_react_ssr_src(); + let env_files = fs::read_dir("./") + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".env")) + .map(|entry| entry.path().to_string_lossy().into_owned()) + .collect::>(); + build_ssr_bundle.start().await; build_rust_src.start().await; @@ -110,8 +128,7 @@ pub async fn watch() -> Result<()> { should_reload_rust_server = true } - // Either tsx, jsx and mdx - if file_path.ends_with("sx") || file_path.ends_with("mdx") { + if ssr_reload_needed(path.0.file_name(), file_path) { should_reload_ssr_bundle = true } } @@ -137,8 +154,11 @@ pub async fn watch() -> Result<()> { action })?; - // watch the current directory - wx.config.pathset(["./src"]); + // watch the current directory and all types of .env file + let mut paths_to_watch = vec!["./src".to_string()]; + paths_to_watch.extend(env_files); + + wx.config.pathset(paths_to_watch); let _ = wx.main().await.into_diagnostic()?; Ok(()) diff --git a/crates/tuono_lib/src/env.rs b/crates/tuono_lib/src/env.rs new file mode 100644 index 00000000..ba30a607 --- /dev/null +++ b/crates/tuono_lib/src/env.rs @@ -0,0 +1,281 @@ +use crate::mode::Mode; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; + +#[derive(Clone, Debug)] +pub struct EnvVarManager { + env_files: Vec, + system_env_names: HashSet, + pub env_vars: HashMap, +} + +impl EnvVarManager { + pub fn new(mode: Mode) -> Self { + let mut env_files = vec![String::from(".env"), String::from(".env.local")]; + + let mode_name = match mode { + Mode::Dev => "development", + Mode::Prod => "production", + }; + + env_files.push(format!(".env.{}", mode_name)); + env_files.push(String::from(".env.local")); + env_files.push(format!(".env.{}.local", mode_name)); + + let system_env_names: HashSet = env::vars().map(|(k, _)| k).collect(); + let env_vars: HashMap = env::vars().collect(); + + let mut manager = Self { + env_files, + system_env_names, + env_vars, + }; + + manager.reload_variables(); + manager.load_into_env(); + manager + } + + fn reload_variables(&mut self) { + for env_file in &self.env_files { + if let Ok(contents) = fs::read_to_string(env_file) { + for line in contents.lines() { + if let Some((key, mut value)) = line.split_once('=') { + if value.starts_with('"') && value.ends_with('"') { + value = &value[1..value.len() - 1]; + } + + let key = key.trim().to_string(); + let value = value.trim().to_string(); + + if self.system_env_names.contains(&key) { + continue; // Skip if key exists in system env + } + + self.env_vars.insert(key, value); + } + } + } + } + + self.load_into_env(); + } + + fn load_into_env(&self) { + for (key, value) in &self.env_vars { + env::set_var(key, value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mode::Mode; + use serial_test::serial; + + struct MockEnv { + files: Vec, + vars: HashMap, + } + + impl MockEnv { + fn new() -> Self { + Self { + files: Vec::new(), + vars: HashMap::new(), + } + } + + fn add_system_var(&mut self, k: &str, v: &str) { + self.vars.insert(k.to_string(), v.to_string()); + env::set_var(k, v); + } + + pub fn setup_env_file(&mut self, file_name: &str, contents: &str) { + self.files.push(file_name.to_string()); + fs::write(file_name, contents).expect("Failed to write test .env file"); + } + } + + impl Drop for MockEnv { + fn drop(&mut self) { + for file in self.files.iter() { + _ = fs::remove_file(file.as_str()); + } + + for var in self.vars.iter() { + env::remove_var(var.0) + } + } + } + + #[test] + #[serial] + fn test_system_env_var_precedence() { + let mut env = MockEnv::new(); + + env.add_system_var("TEST_KEY", "system_value"); + + env.setup_env_file(".env", "TEST_KEY=file_value"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("TEST_KEY").unwrap(), "system_value"); + } + + #[test] + #[serial] + fn test_mode_specific_env_var_precedence() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", "TEST_KEY=base_value"); + env.setup_env_file(".env.development", "TEST_KEY=development_value"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("TEST_KEY").unwrap(), "development_value"); + } + + #[test] + #[serial] + fn test_local_env_var_precedence() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", "TEST_KEY=base_value"); + env.setup_env_file(".env.local", "TEST_KEY=local_value"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("TEST_KEY").unwrap(), "local_value"); + } + + #[test] + #[serial] + fn test_mode_local_env_var_precedence() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", "TEST_KEY=base_value"); + env.setup_env_file(".env.development", "TEST_KEY=development_value"); + env.setup_env_file(".env.development.local", "TEST_KEY=local_dev_value"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value"); + } + + #[test] + #[serial] + fn test_empty_env_file() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", ""); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert!(env::var("NON_EXISTENT_KEY").is_err()); + } + + #[test] + #[serial] + fn test_malformed_env_entries() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert!(env::var("INVALID_LINE").is_err()); + assert!(env::var("MISSING_EQUALS_SIGN").is_err()); + } + + #[test] + #[serial] + fn test_quoted_values_parsing() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", r#"TEST_KEY="quoted_value""#); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value"); + } + + #[test] + #[serial] + fn test_non_existent_env_file() { + let mut env = MockEnv::new(); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert!(env::var("NON_EXISTENT_KEY").is_err()); + } + + #[test] + #[serial] + fn test_multiple_env_vars() { + let mut env = MockEnv::new(); + + env.setup_env_file(".env", "KEY1=value1\nKEY2=value2"); + + let manager = EnvVarManager::new(Mode::Dev); + + manager.load_into_env(); + + for env_var in manager.env_vars { + env.vars.insert(env_var.0, env_var.1); + } + + assert_eq!(env::var("KEY1").unwrap(), "value1"); + assert_eq!(env::var("KEY2").unwrap(), "value2"); + } +} diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index 96ff6996..0f36c1e4 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -5,6 +5,7 @@ mod catch_all; mod config; +mod env; mod logger; mod manifest; mod mode; diff --git a/crates/tuono_lib/src/server.rs b/crates/tuono_lib/src/server.rs index 0bb5c24c..0c4dba3b 100644 --- a/crates/tuono_lib/src/server.rs +++ b/crates/tuono_lib/src/server.rs @@ -7,6 +7,7 @@ use ssr_rs::Ssr; use tower_http::services::ServeDir; use tuono_internal::config::Config; +use crate::env::EnvVarManager; use crate::{ catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy, vite_websocket_proxy::vite_websocket_proxy, @@ -62,6 +63,8 @@ impl Server { let server_address = format!("{}:{}", config.server.host, config.server.port); + let _env_var_manager = EnvVarManager::new(mode); + Server { router, mode, diff --git a/crates/tuono_lib/tests/server_test.rs b/crates/tuono_lib/tests/server_test.rs index 93b8c247..ce0558f2 100644 --- a/crates/tuono_lib/tests/server_test.rs +++ b/crates/tuono_lib/tests/server_test.rs @@ -147,3 +147,25 @@ async fn it_reads_the_path_parameter() { assert!(response.status().is_success()); assert_eq!(response.text().await.unwrap(), "url_parameter"); } + +#[tokio::test] +#[serial] +async fn it_reads_an_env_var() { + let app = MockTuonoServer::spawn().await; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let server_url = format!("http://{}:{}", &app.address, &app.port); + + let response = client + .get(format!("{server_url}/env")) + .send() + .await + .expect("Failed to execute request."); + + assert!(response.status().is_success()); + assert_eq!(response.text().await.unwrap(), "foobar"); +} diff --git a/crates/tuono_lib/tests/utils/env.rs b/crates/tuono_lib/tests/utils/env.rs new file mode 100644 index 00000000..8ac74cad --- /dev/null +++ b/crates/tuono_lib/tests/utils/env.rs @@ -0,0 +1,7 @@ +use std::env; +use tuono_lib::Request; + +#[tuono_lib::api(GET)] +pub async fn test_env(_req: Request) -> String { + env::var("MY_TEST_KEY").unwrap_or("error".parse().unwrap()) +} diff --git a/crates/tuono_lib/tests/utils/mock_server.rs b/crates/tuono_lib/tests/utils/mock_server.rs index 4f490847..93d8d8e7 100644 --- a/crates/tuono_lib/tests/utils/mock_server.rs +++ b/crates/tuono_lib/tests/utils/mock_server.rs @@ -9,6 +9,7 @@ use tuono_lib::{axum::Router, tuono_internal_init_v8_platform, Mode, Server}; use crate::utils::catch_all::get_tuono_internal_api as catch_all; use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter; +use crate::utils::env::get_tuono_internal_api as test_env; use crate::utils::health_check::get_tuono_internal_api as health_check; use crate::utils::route as html_route; use crate::utils::route::tuono_internal_api as route_api; @@ -74,13 +75,16 @@ impl MockTuonoServer { r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#, ); + add_file_with_content("./.env", r#"MY_TEST_KEY="foobar""#); + let router = Router::new() .route("/", get(html_route::tuono_internal_route)) .route("/tuono/data", get(html_route::tuono_internal_api)) .route("/health_check", get(health_check)) .route("/route-api", get(route_api)) .route("/catch_all/{*catch_all}", get(catch_all)) - .route("/dynamic/{parameter}", get(dynamic_parameter)); + .route("/dynamic/{parameter}", get(dynamic_parameter)) + .route("/env", get(test_env)); let server = Server::init(router, Mode::Prod).await; diff --git a/crates/tuono_lib/tests/utils/mod.rs b/crates/tuono_lib/tests/utils/mod.rs index ed23aaf4..9bf184ab 100644 --- a/crates/tuono_lib/tests/utils/mod.rs +++ b/crates/tuono_lib/tests/utils/mod.rs @@ -1,5 +1,6 @@ pub mod catch_all; pub mod dynamic_parameter; +pub mod env; pub mod health_check; pub mod mock_server; pub mod route;