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..d11c1240ed --- /dev/null +++ b/e2e/env/test_env_template_read_file @@ -0,0 +1,119 @@ +#!/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) +# Create a nested project structure with config files at different levels +TEST_ROOT=$PWD +mkdir -p project/subdir +cd project + +# Create version files at each level +echo "1.0.0" >version.txt +echo "2.0.0" >subdir/version.txt + +# Create a data file only in parent +echo "parent-only-data" >parent-data.txt + +# Create mise.toml in parent directory +cat <mise.toml +[env] +PARENT_VERSION = "{{ read_file(path='version.txt') | trim }}" +PARENT_DATA = "{{ read_file(path='parent-data.txt') | trim }}" +EOF + +# Create mise.toml in subdirectory that reads its own version.txt +cat <subdir/mise.toml +[env] +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 + +# 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" + +# Go back to original test directory +cd "$TEST_ROOT" + +# 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..8e98765ae9 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,37 @@ mod tests { assert_eq!(s.trim(), "ok"); } + #[tokio::test] + #[cfg(unix)] + async fn test_read_file() { + use std::fs; + use tempfile::TempDir; + + let _config = Config::get().await.unwrap(); + + // 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 { let config_root = Path::new("/"); let mut tera_ctx = BASE_CONTEXT.clone();