From 6c17eb9a1d55206a4878f47eaa9fe2c38d2c4509 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:07:09 -0500 Subject: [PATCH 1/3] feat(template): add read_file() function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new template function `read_file(path)` that reads the contents of a file relative to the config_root. This allows config files to include content from external files during template rendering. - Paths are resolved relative to the directory containing the mise.toml file - Returns the raw file contents as a string - Can be combined with filters like trim, replace, etc. Example usage: ```toml [env] VERSION = "{{ read_file(path='VERSION') | trim }}" ``` 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/templates.md | 9 ++- e2e/env/test_env_template_read_file | 116 ++++++++++++++++++++++++++++ mise.lock | 5 ++ src/tera.rs | 38 ++++++++- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100755 e2e/env/test_env_template_read_file diff --git a/docs/templates.md b/docs/templates.md index 702ea237fa..40bb488880 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -175,12 +175,19 @@ Mise offers additional functions: - `choice(n, alphabet)` - Generate a string of `n` with random sample with replacement of `alphabet`. For example, `choice(64, HEX)` will generate a random 64-character lowercase hex string. +- `read_file(path) -> String` – Reads the contents of a file at the given path and returns + it as a string. -An example of function using `exec`: +Examples of functions: ```toml +# Using exec to get command output [alias.node.versions] current = "{{ exec(command='node --version') }}" + +# Using read_file to include content from a file +[env] +VERSION = "{{ read_file(path='VERSION') | trim }}" ``` ### Exec Options diff --git a/e2e/env/test_env_template_read_file b/e2e/env/test_env_template_read_file new file mode 100755 index 0000000000..1fccfadb31 --- /dev/null +++ b/e2e/env/test_env_template_read_file @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 + +# Test basic read_file functionality +cat <test_version.txt +1.2.3 +EOF + +cat <mise.toml +[env] +VERSION = "{{ read_file(path='test_version.txt') | trim }}" +EOF + +assert_contains "mise env -s bash | grep VERSION" "export VERSION=1.2.3" + +# Test read_file with relative path +mkdir -p subdir +cat <subdir/info.txt +test-content +EOF + +cat <mise.toml +[env] +CONTENT = "{{ read_file(path='subdir/info.txt') | trim }}" +EOF + +assert_contains "mise env -s bash | grep CONTENT" "export CONTENT=test-content" + +# Test read_file with multiple lines +cat <multiline.txt +line1 +line2 +line3 +EOF + +cat <mise.toml +[env] +MULTILINE = "{{ read_file(path='multiline.txt') | replace(from='\n', to=' ') | trim }}" +EOF + +assert_contains "mise env -s bash | grep MULTILINE" "export MULTILINE='line1 line2 line3'" + +# Test read_file with vars +cat <config.txt +production +EOF + +cat <mise.toml +[vars] +CONFIG_FILE = "config.txt" + +[env] +ENVIRONMENT = "{{ read_file(path=vars.CONFIG_FILE) | trim }}" +EOF + +assert_contains "mise env -s bash | grep ENVIRONMENT" "export ENVIRONMENT=production" + +# Test read_file with templated content (should read raw content) +cat <template_test.txt +{{ env.USER }} +EOF + +cat <mise.toml +[env] +RAW_CONTENT = "{{ read_file(path='template_test.txt') | trim }}" +EOF + +assert_contains "mise env -s bash | grep RAW_CONTENT" "export RAW_CONTENT='{{ env.USER }}'" + +# Test read_file is relative to config_root (not CWD) +mkdir -p project/config +cat <project/config/version.txt +2.0.0 +EOF + +cat <project/config/mise.toml +[env] +PROJECT_VERSION = "{{ read_file(path='version.txt') | trim }}" +EOF + +# Save current directory +ORIG_DIR=$PWD + +# Run mise from a different directory to verify read_file is relative to config_root +cd /tmp +assert_contains "mise env -s bash -C $ORIG_DIR/project/config | grep PROJECT_VERSION" "export PROJECT_VERSION=2.0.0" +cd "$ORIG_DIR" + +# Test nested config with read_file relative to its own directory +mkdir -p project/sub +cat <project/data.txt +parent-data +EOF +cat <project/sub/data.txt +sub-data +EOF + +cat <project/mise.toml +[env] +PARENT_DATA = "{{ read_file(path='data.txt') | trim }}" +EOF + +cat <project/sub/mise.toml +[env] +SUB_DATA = "{{ read_file(path='data.txt') | trim }}" +EOF + +# Check from parent directory +assert_contains "mise env -s bash -C project | grep PARENT_DATA" "export PARENT_DATA=parent-data" + +# Check from sub directory (should use sub's data.txt) +assert_contains "mise env -s bash -C project/sub | grep SUB_DATA" "export SUB_DATA=sub-data" + +# Cleanup +rm -f test_version.txt multiline.txt config.txt template_test.txt +rm -rf subdir project diff --git a/mise.lock b/mise.lock index a2369f4548..eb6300dc20 100644 --- a/mise.lock +++ b/mise.lock @@ -113,6 +113,11 @@ checksum = "blake3:dbfd6f2b7d0f399f6ec8cd251a76656c27af25aa327c015ef1acf4307c6a4 size = 6984984 url = "https://github.com/jdx/hk/releases/download/v1.15.5/hk-x86_64-unknown-linux-gnu.tar.gz" +[tools.hk.platforms.macos-arm64] +checksum = "blake3:98c9e44790bcff4b4988e6a13f9524272164e98fd475dc43f364cfb41bb4e897" +size = 6016496 +url = "https://github.com/jdx/hk/releases/download/v1.15.5/hk-aarch64-apple-darwin.tar.gz" + [[tools.jq]] version = "1.8.1" backend = "aqua:jqlang/jq" diff --git a/src/tera.rs b/src/tera.rs index 2c8e0a8751..4a11a889a0 100644 --- a/src/tera.rs +++ b/src/tera.rs @@ -303,7 +303,8 @@ static TERA: Lazy = Lazy::new(|| { pub fn get_tera(dir: Option<&Path>) -> Tera { let mut tera = TERA.clone(); let dir = dir.map(PathBuf::from); - tera.register_function("exec", tera_exec(dir, env::PRISTINE_ENV.clone())); + tera.register_function("exec", tera_exec(dir.clone(), env::PRISTINE_ENV.clone())); + tera.register_function("read_file", tera_read_file(dir)); tera } @@ -374,6 +375,32 @@ pub fn tera_exec( } } +pub fn tera_read_file( + dir: Option, +) -> impl Fn(&HashMap) -> tera::Result { + move |args: &HashMap| -> tera::Result { + match args.get("path") { + Some(Value::String(path_str)) => { + let path = if let Some(ref base_dir) = dir { + // Resolve relative to config directory + base_dir.join(path_str) + } else { + // Use path as-is if no directory context + PathBuf::from(path_str) + }; + + match std::fs::read_to_string(&path) { + Ok(contents) => Ok(Value::String(contents)), + Err(e) => { + Err(format!("Failed to read file '{}': {}", path.display(), e).into()) + } + } + } + _ => Err("read_file path must be a string".into()), + } + } +} + #[cfg(test)] mod tests { use crate::config::Config; @@ -654,6 +681,15 @@ mod tests { assert_eq!(s.trim(), "ok"); } + #[tokio::test] + #[cfg(unix)] + async fn test_read_file() { + let _config = Config::get().await.unwrap(); + let s = render(r#"{{ read_file(path="../fixtures/shorthands.toml") }}"#); + assert!(s.contains("elixir")); + assert!(s.contains("nodejs")); + } + fn render(s: &str) -> String { let config_root = Path::new("/"); let mut tera_ctx = BASE_CONTEXT.clone(); From b9e5db9eab8376c4cd087ac60e54690d34afcef1 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:28:35 -0500 Subject: [PATCH 2/3] test: improve read_file test to properly verify config_root behavior The test now properly verifies that read_file() resolves paths relative to each config file's directory, not the current working directory. It tests with nested config files at different levels, each reading their own local files, and confirms that parent env vars are accessible from subdirectories. --- e2e/env/test_env_template_read_file | 67 +++++++++++++++-------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/e2e/env/test_env_template_read_file b/e2e/env/test_env_template_read_file index 1fccfadb31..d11c1240ed 100755 --- a/e2e/env/test_env_template_read_file +++ b/e2e/env/test_env_template_read_file @@ -68,48 +68,51 @@ EOF assert_contains "mise env -s bash | grep RAW_CONTENT" "export RAW_CONTENT='{{ env.USER }}'" # Test read_file is relative to config_root (not CWD) -mkdir -p project/config -cat <project/config/version.txt -2.0.0 -EOF - -cat <project/config/mise.toml -[env] -PROJECT_VERSION = "{{ read_file(path='version.txt') | trim }}" -EOF - -# Save current directory -ORIG_DIR=$PWD +# Create a nested project structure with config files at different levels +TEST_ROOT=$PWD +mkdir -p project/subdir +cd project -# Run mise from a different directory to verify read_file is relative to config_root -cd /tmp -assert_contains "mise env -s bash -C $ORIG_DIR/project/config | grep PROJECT_VERSION" "export PROJECT_VERSION=2.0.0" -cd "$ORIG_DIR" +# Create version files at each level +echo "1.0.0" >version.txt +echo "2.0.0" >subdir/version.txt -# Test nested config with read_file relative to its own directory -mkdir -p project/sub -cat <project/data.txt -parent-data -EOF -cat <project/sub/data.txt -sub-data -EOF +# Create a data file only in parent +echo "parent-only-data" >parent-data.txt -cat <project/mise.toml +# Create mise.toml in parent directory +cat <mise.toml [env] -PARENT_DATA = "{{ read_file(path='data.txt') | trim }}" +PARENT_VERSION = "{{ read_file(path='version.txt') | trim }}" +PARENT_DATA = "{{ read_file(path='parent-data.txt') | trim }}" EOF -cat <project/sub/mise.toml +# Create mise.toml in subdirectory that reads its own version.txt +cat <subdir/mise.toml [env] -SUB_DATA = "{{ read_file(path='data.txt') | trim }}" +SUB_VERSION = "{{ read_file(path='version.txt') | trim }}" +# This should fail if uncommented - file doesn't exist relative to subdir: +# SUB_PARENT_DATA = "{{ read_file(path='parent-data.txt') | trim }}" EOF -# Check from parent directory -assert_contains "mise env -s bash -C project | grep PARENT_DATA" "export PARENT_DATA=parent-data" +# Test from parent directory - should read parent's version.txt (1.0.0) +assert_contains "mise env -s bash | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0" +assert_contains "mise env -s bash | grep PARENT_DATA" "export PARENT_DATA=parent-only-data" + +# Test from subdirectory - should read both configs, each with their own files +cd subdir +assert_contains "mise env -s bash | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0" +assert_contains "mise env -s bash | grep SUB_VERSION" "export SUB_VERSION=2.0.0" +assert_contains "mise env -s bash | grep PARENT_DATA" "export PARENT_DATA=parent-only-data" + +# Now test that running mise from a completely different directory still works +PROJECT_DIR=$TEST_ROOT/project +cd /tmp +assert_contains "mise env -s bash -C $PROJECT_DIR | grep PARENT_VERSION" "export PARENT_VERSION=1.0.0" +assert_contains "mise env -s bash -C $PROJECT_DIR/subdir | grep SUB_VERSION" "export SUB_VERSION=2.0.0" -# Check from sub directory (should use sub's data.txt) -assert_contains "mise env -s bash -C project/sub | grep SUB_DATA" "export SUB_DATA=sub-data" +# Go back to original test directory +cd "$TEST_ROOT" # Cleanup rm -f test_version.txt multiline.txt config.txt template_test.txt From ff03000ca20d26dde8cbc8e2ade48b88300a2df2 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:41:00 -0500 Subject: [PATCH 3/3] test: fix read_file unit test to use temp directory The test was using a hardcoded path that doesn't exist. Fixed by creating a temp directory and test file to properly verify the read_file function works correctly. --- src/tera.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/tera.rs b/src/tera.rs index 4a11a889a0..8e98765ae9 100644 --- a/src/tera.rs +++ b/src/tera.rs @@ -684,10 +684,32 @@ mod tests { #[tokio::test] #[cfg(unix)] async fn test_read_file() { + use std::fs; + use tempfile::TempDir; + let _config = Config::get().await.unwrap(); - let s = render(r#"{{ read_file(path="../fixtures/shorthands.toml") }}"#); - assert!(s.contains("elixir")); - assert!(s.contains("nodejs")); + + // Create a temp directory and test file + let temp_dir = TempDir::new().unwrap(); + let test_file_path = temp_dir.path().join("test.txt"); + fs::write(&test_file_path, "test content\nwith multiple lines").unwrap(); + + // Test with the temp file + let mut tera_ctx = BASE_CONTEXT.clone(); + tera_ctx.insert("config_root", &temp_dir.path().to_str().unwrap()); + tera_ctx.insert("cwd", temp_dir.path().to_str().unwrap()); + let mut tera = get_tera(Some(temp_dir.path())); + + let s = tera + .render_str(r#"{{ read_file(path="test.txt") }}"#, &tera_ctx) + .unwrap(); + assert_eq!(s, "test content\nwith multiple lines"); + + // Test with trim filter + let s = tera + .render_str(r#"{{ read_file(path="test.txt") | trim }}"#, &tera_ctx) + .unwrap(); + assert_eq!(s, "test content\nwith multiple lines"); } fn render(s: &str) -> String {