diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 9477b0a..2b24c2d 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -84,6 +84,35 @@ pub struct QemuConfig { } impl QemuConfig { + fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + self.args = self + .args + .iter() + .map(|arg| tool.replace_string(arg)) + .collect::>>()?; + self.success_regex = self + .success_regex + .iter() + .map(|arg| tool.replace_string(arg)) + .collect::>>()?; + self.fail_regex = self + .fail_regex + .iter() + .map(|arg| tool.replace_string(arg)) + .collect::>>()?; + self.shell_prefix = self + .shell_prefix + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.shell_init_cmd = self + .shell_init_cmd + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + Ok(()) + } + fn normalize(&mut self, config_name: &str) -> anyhow::Result<()> { normalize_shell_init_config( &mut self.shell_prefix, @@ -171,6 +200,9 @@ async fn load_or_create_qemu_config( explicit_config_path: Option, overrides: QemuDefaultOverrides, ) -> anyhow::Result { + let explicit_config_path = explicit_config_path + .map(|path| tool.replace_path_variables(path)) + .transpose()?; let config_path = resolve_qemu_config_path(tool, explicit_config_path)?; info!("Using QEMU config file: {}", config_path.display()); @@ -180,6 +212,7 @@ async fn load_or_create_qemu_config( let mut config: QemuConfig = toml::from_str(&content).with_context(|| { format!("failed to parse QEMU config: {}", config_path.display()) })?; + config.replace_strings(tool)?; config.normalize(&format!("QEMU config {}", config_path.display()))?; return Ok(config); } @@ -720,11 +753,13 @@ mod tests { use crate::{ Tool, ToolConfig, + build::config::{BuildConfig, BuildSystem, Cargo}, run::{ output_matcher::{ByteStreamMatcher, StreamMatchKind}, shell_init::ShellAutoInitMatcher, }, }; + use std::collections::HashMap; fn write_single_crate_manifest(dir: &std::path::Path) { std::fs::write( @@ -931,6 +966,65 @@ timeout = 0 assert_eq!(result, manifest.join("qemu-aarch64.toml")); } + #[test] + fn qemu_config_replaces_string_fields() { + let tmp = TempDir::new().unwrap(); + write_single_crate_manifest(tmp.path()); + let mut tool = make_tool(tmp.path()); + tool.ctx.build_config = Some(BuildConfig { + system: BuildSystem::Cargo(Cargo { + env: HashMap::new(), + target: "aarch64-unknown-none".into(), + package: "sample".into(), + features: vec![], + log: None, + extra_config: None, + args: vec![], + pre_build_cmds: vec![], + post_build_cmds: vec![], + to_bin: false, + }), + }); + unsafe { + std::env::set_var("OSTOOL_QEMU_TEST_ENV", "env-ok"); + } + + let mut config = QemuConfig { + args: vec!["${workspace}".into(), "${package}".into()], + success_regex: vec!["${env:OSTOOL_QEMU_TEST_ENV}".into()], + fail_regex: vec!["${workspaceFolder}".into()], + shell_prefix: Some("${workspace}".into()), + shell_init_cmd: Some("${package}".into()), + ..Default::default() + }; + + config.replace_strings(&tool).unwrap(); + + let expected = tmp.path().display().to_string(); + assert_eq!(config.args, vec![expected.clone(), expected.clone()]); + assert_eq!(config.success_regex, vec!["env-ok"]); + assert_eq!(config.fail_regex, vec![expected.clone()]); + assert_eq!(config.shell_prefix.as_deref(), Some(expected.as_str())); + assert_eq!(config.shell_init_cmd.as_deref(), Some(expected.as_str())); + } + + #[test] + fn qemu_config_explicit_path_supports_variables() { + let tmp = TempDir::new().unwrap(); + write_single_crate_manifest(tmp.path()); + let tool = make_tool(tmp.path()); + + let result = resolve_qemu_config_path( + &tool, + Some( + tool.replace_path_variables("${workspace}/qemu.toml".into()) + .unwrap(), + ), + ) + .unwrap(); + assert_eq!(result, tmp.path().join("qemu.toml")); + } + #[test] fn qemu_config_default_path_with_search_dir() { let tmp = TempDir::new().unwrap(); diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index 15ade62..b02ec03 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -28,7 +28,7 @@ use crate::{ tftp, }, sterm::SerialTerm, - utils::{PathResultExt, replace_env_placeholders}, + utils::PathResultExt, }; /// FIT image 生成相关的错误消息常量 @@ -73,6 +73,70 @@ pub struct UbootConfig { } impl UbootConfig { + fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + self.serial = tool.replace_string(&self.serial)?; + self.baud_rate = tool.replace_string(&self.baud_rate)?; + self.dtb_file = self + .dtb_file + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.kernel_load_addr = self + .kernel_load_addr + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.fit_load_addr = self + .fit_load_addr + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.board_reset_cmd = self + .board_reset_cmd + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.board_power_off_cmd = self + .board_power_off_cmd + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.success_regex = self + .success_regex + .iter() + .map(|value| tool.replace_string(value)) + .collect::>>()?; + self.fail_regex = self + .fail_regex + .iter() + .map(|value| tool.replace_string(value)) + .collect::>>()?; + self.uboot_cmd = self + .uboot_cmd + .as_ref() + .map(|values| { + values + .iter() + .map(|value| tool.replace_string(value)) + .collect::>>() + }) + .transpose()?; + self.shell_prefix = self + .shell_prefix + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.shell_init_cmd = self + .shell_init_cmd + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + if let Some(net) = &mut self.net { + net.replace_strings(tool)?; + } + Ok(()) + } + pub fn kernel_load_addr_int(&self) -> Option { self.addr_int(self.kernel_load_addr.as_ref()) } @@ -115,6 +179,33 @@ pub struct Net { pub tftp_dir: Option, } +impl Net { + fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + self.interface = tool.replace_string(&self.interface)?; + self.board_ip = self + .board_ip + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.gatewayip = self + .gatewayip + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.netmask = self + .netmask + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + self.tftp_dir = self + .tftp_dir + .as_deref() + .map(|value| tool.replace_string(value)) + .transpose()?; + Ok(()) + } +} + #[derive(Debug, Clone)] pub struct RunUbootArgs { pub config: Option, @@ -124,18 +215,17 @@ pub struct RunUbootArgs { impl Tool { pub async fn run_uboot(&mut self, args: RunUbootArgs) -> anyhow::Result<()> { let config_path = match args.config.clone() { - Some(path) => path, + Some(path) => self.replace_path_variables(path)?, None => self.workspace_dir().join(".uboot.toml"), }; let config = match fs::read_to_string(&config_path).await { Ok(content) => { println!("Using U-Boot config: {}", config_path.display()); - let config_content = replace_env_placeholders(&content)?; - let mut config: UbootConfig = - toml::from_str(&config_content).with_context(|| { - format!("failed to parse U-Boot config: {}", config_path.display()) - })?; + let mut config: UbootConfig = toml::from_str(&content).with_context(|| { + format!("failed to parse U-Boot config: {}", config_path.display()) + })?; + config.replace_strings(self)?; config.normalize(&format!("U-Boot config {}", config_path.display()))?; config } @@ -718,7 +808,12 @@ fn build_network_boot_request( #[cfg(test)] mod tests { - use super::{UbootConfig, build_network_boot_request, timeout_duration}; + use super::{Net, UbootConfig, build_network_boot_request, timeout_duration}; + use crate::{ + Tool, ToolConfig, + build::config::{BuildConfig, BuildSystem, Cargo}, + }; + use std::collections::HashMap; use std::time::Duration; #[test] @@ -806,4 +901,96 @@ timeout = 0 assert_eq!(config.timeout, Some(0)); } + + #[test] + fn uboot_config_replaces_string_fields() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap(); + + let mut tool = Tool::new(ToolConfig { + manifest: Some(tmp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + tool.ctx.build_config = Some(BuildConfig { + system: BuildSystem::Cargo(Cargo { + env: HashMap::new(), + target: "aarch64-unknown-none".into(), + package: "sample".into(), + features: vec![], + log: None, + extra_config: None, + args: vec![], + pre_build_cmds: vec![], + post_build_cmds: vec![], + to_bin: false, + }), + }); + unsafe { + std::env::set_var("OSTOOL_UBOOT_TEST_ENV", "env-ok"); + } + + let mut config = UbootConfig { + serial: "${workspace}/tty".into(), + baud_rate: "${env:OSTOOL_UBOOT_TEST_ENV}".into(), + dtb_file: Some("${package}/board.dtb".into()), + kernel_load_addr: Some("${workspaceFolder}".into()), + fit_load_addr: Some("${package}".into()), + board_reset_cmd: Some("${workspace}".into()), + board_power_off_cmd: Some("${package}".into()), + success_regex: vec!["${workspace}".into()], + fail_regex: vec!["${package}".into()], + uboot_cmd: Some(vec!["setenv boot ${workspace}".into()]), + shell_prefix: Some("${workspace}".into()), + shell_init_cmd: Some("${package}".into()), + net: Some(Net { + interface: "${env:OSTOOL_UBOOT_TEST_ENV}".into(), + board_ip: Some("${workspace}".into()), + gatewayip: Some("${package}".into()), + netmask: Some("${workspaceFolder}".into()), + tftp_dir: Some("${package}/tftp".into()), + }), + ..Default::default() + }; + + config.replace_strings(&tool).unwrap(); + + let expected = tmp.path().display().to_string(); + assert_eq!(config.serial, format!("{expected}/tty")); + assert_eq!(config.baud_rate, "env-ok"); + assert_eq!( + config.dtb_file.as_deref(), + Some(format!("{expected}/board.dtb").as_str()) + ); + assert_eq!(config.kernel_load_addr.as_deref(), Some(expected.as_str())); + assert_eq!(config.fit_load_addr.as_deref(), Some(expected.as_str())); + assert_eq!(config.board_reset_cmd.as_deref(), Some(expected.as_str())); + assert_eq!( + config.board_power_off_cmd.as_deref(), + Some(expected.as_str()) + ); + assert_eq!(config.success_regex, vec![expected.clone()]); + assert_eq!(config.fail_regex, vec![expected.clone()]); + assert_eq!( + config.uboot_cmd, + Some(vec![format!("setenv boot {expected}")]) + ); + assert_eq!(config.shell_prefix.as_deref(), Some(expected.as_str())); + assert_eq!(config.shell_init_cmd.as_deref(), Some(expected.as_str())); + let net = config.net.unwrap(); + assert_eq!(net.interface, "env-ok"); + assert_eq!(net.board_ip.as_deref(), Some(expected.as_str())); + assert_eq!(net.gatewayip.as_deref(), Some(expected.as_str())); + assert_eq!(net.netmask.as_deref(), Some(expected.as_str())); + assert_eq!( + net.tftp_dir.as_deref(), + Some(format!("{expected}/tftp").as_str()) + ); + } } diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs index 0054324..f206206 100644 --- a/ostool/src/tool.rs +++ b/ostool/src/tool.rs @@ -18,7 +18,7 @@ use crate::{ someboot, }, ctx::AppContext, - utils::PathResultExt, + utils::{PathResultExt, replace_placeholders}, }; /// Static configuration used to initialize a [`Tool`]. @@ -140,14 +140,9 @@ impl Tool { /// Creates a new command builder for the given program. pub fn command(&self, program: &str) -> crate::utils::Command { - let workspace_dir = self.workspace_dir.clone(); - let mut command = crate::utils::Command::new(program, &self.manifest_dir, move |s| { - let raw = s.to_string_lossy(); - raw.replace( - "${workspaceFolder}", - format!("{}", workspace_dir.display()).as_ref(), - ) - }); + let tool = self.clone(); + let mut command = + crate::utils::Command::new(program, &self.manifest_dir, move |s| tool.replace_value(s)); command.env("WORKSPACE_FOLDER", self.workspace_dir.display().to_string()); command } @@ -381,11 +376,48 @@ impl Tool { where S: AsRef, { - let raw = value.as_ref().to_string_lossy(); - raw.replace( - "${workspaceFolder}", - format!("{}", self.workspace_dir.display()).as_ref(), - ) + self.replace_value(value) + } + + pub fn replace_value(&self, value: S) -> String + where + S: AsRef, + { + self.replace_string(&value.as_ref().to_string_lossy()) + .unwrap_or_else(|_| value.as_ref().to_string_lossy().into_owned()) + } + + pub fn replace_string(&self, input: &str) -> anyhow::Result { + let package_dir = self.package_root_for_variables()?; + let workspace_dir = self.workspace_dir.display().to_string(); + let package_dir = package_dir.display().to_string(); + let tmp_dir = std::env::temp_dir().display().to_string(); + + replace_placeholders(input, |placeholder| { + let value = match placeholder { + "workspace" | "workspaceFolder" => Some(workspace_dir.clone()), + "package" => Some(package_dir.clone()), + "tmpDir" => Some(tmp_dir.clone()), + p if p.starts_with("env:") => Some(std::env::var(&p[4..]).unwrap_or_default()), + _ => None, + }; + Ok(value) + }) + } + + pub fn replace_path_variables(&self, path: PathBuf) -> anyhow::Result { + Ok(PathBuf::from(self.replace_string(&path.to_string_lossy())?)) + } + + fn package_root_for_variables(&self) -> anyhow::Result { + if let Some(BuildConfig { + system: BuildSystem::Cargo(cargo), + }) = &self.ctx.build_config + { + return self.resolve_package_manifest_dir(&cargo.package); + } + + Ok(self.manifest_dir.clone()) } pub fn ui_hocks(&self) -> Vec { @@ -502,8 +534,10 @@ fn resolve_manifest_path(input: Option) -> anyhow::Result { #[cfg(test)] mod tests { use super::{Tool, ToolConfig, resolve_manifest_context}; + use crate::build::config::{BuildConfig, BuildSystem, Cargo}; use crate::run::qemu::resolve_qemu_config_path_in_dir; use object::Architecture; + use std::collections::HashMap; #[tokio::test] async fn set_elf_artifact_path_updates_dirs_and_arch() { @@ -647,4 +681,163 @@ mod tests { assert_eq!(resolved, kernel_dir.join(".qemu-aarch64.toml")); } + + #[test] + fn replace_string_uses_workspace_and_legacy_workspacefolder() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(temp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + + let replaced = tool + .replace_string("${workspace}:${workspaceFolder}") + .unwrap(); + let expected = temp.path().display().to_string(); + assert_eq!(replaced, format!("{expected}:{expected}")); + } + + #[test] + fn replace_string_uses_cross_platform_tmpdir() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(temp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + + let replaced = tool.replace_string("${tmpDir}").unwrap(); + assert_eq!(replaced, std::env::temp_dir().display().to_string()); + } + + #[test] + fn replace_string_uses_package_dir_from_build_config() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\", \"kernel\"]\nresolver = \"3\"\n", + ) + .unwrap(); + + let app_dir = temp.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let kernel_dir = temp.path().join("kernel"); + std::fs::create_dir_all(kernel_dir.join("src")).unwrap(); + std::fs::write( + kernel_dir.join("Cargo.toml"), + "[package]\nname = \"kernel\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(kernel_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let mut tool = Tool::new(ToolConfig { + manifest: Some(app_dir), + ..Default::default() + }) + .unwrap(); + tool.ctx.build_config = Some(BuildConfig { + system: BuildSystem::Cargo(Cargo { + env: HashMap::new(), + target: "aarch64-unknown-none".into(), + package: "kernel".into(), + features: vec![], + log: None, + extra_config: None, + args: vec![], + pre_build_cmds: vec![], + post_build_cmds: vec![], + to_bin: false, + }), + }); + + let replaced = tool.replace_string("${package}").unwrap(); + assert_eq!(replaced, kernel_dir.display().to_string()); + } + + #[test] + fn replace_string_falls_back_to_manifest_dir_for_package() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(temp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + + let replaced = tool.replace_string("${package}").unwrap(); + assert_eq!(replaced, temp.path().display().to_string()); + } + + #[test] + fn command_replaces_args_and_env() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(temp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + + let mut cmd = tool.command("echo"); + cmd.arg("${workspace}"); + cmd.env("PKG_DIR", "${package}"); + + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec![temp.path().display().to_string()]); + + let envs: Vec<(String, String)> = cmd + .get_envs() + .filter_map(|(k, v)| { + Some(( + k.to_string_lossy().into_owned(), + v?.to_string_lossy().into_owned(), + )) + }) + .collect(); + assert!( + envs.iter() + .any(|(k, v)| k == "PKG_DIR" && v == &temp.path().display().to_string()) + ); + } } diff --git a/ostool/src/utils.rs b/ostool/src/utils.rs index 1de2c60..d2d3573 100644 --- a/ostool/src/utils.rs +++ b/ostool/src/utils.rs @@ -207,10 +207,24 @@ where /// assert_eq!(result, "Value: hello"); /// ``` pub fn replace_env_placeholders(input: &str) -> anyhow::Result { - use std::env; + replace_placeholders(input, |placeholder| { + if let Some(env_var_name) = placeholder.strip_prefix("env:") { + return Ok(Some(std::env::var(env_var_name).unwrap_or_default())); + } + + Ok(None) + }) +} - // 使用正则表达式匹配 ${env:VAR_NAME} 格式 - // 由于我们要避免外部依赖,使用简单的字符串解析 +/// Replaces placeholders in a string using a caller-provided resolver. +/// +/// Placeholders use the format `${name}`. The resolver can choose to replace +/// a placeholder by returning `Some(value)` or keep it unchanged with `None`. +/// This function preserves malformed or unknown placeholders as-is. +pub fn replace_placeholders(input: &str, mut resolver: F) -> anyhow::Result +where + F: FnMut(&str) -> anyhow::Result>, +{ let mut result = String::new(); let mut chars = input.chars().peekable(); @@ -240,28 +254,17 @@ pub fn replace_env_placeholders(input: &str) -> anyhow::Result { } } - // 只有找到完整的占位符才进行处理 - if found_closing_brace && placeholder.starts_with("env:") { - let env_var_name = &placeholder[4..]; // 跳过 "env:" - - // 获取环境变量值,如果不存在则替换为空字符串 - match env::var(env_var_name) { - Ok(value) => { - println!("Using {env_var_name}={value}"); - result.push_str(&value) - } - Err(_) => { - // 环境变量不存在时替换为空字符串,不返回错误 - result.push_str(""); - } + if found_closing_brace { + if let Some(value) = resolver(&placeholder)? { + result.push_str(&value); + } else { + result.push_str("${"); + result.push_str(&placeholder); + result.push('}'); } } else { - // 不是完整的占位符或不是环境变量占位符,保持原样 result.push_str("${"); result.push_str(&placeholder); - if found_closing_brace { - result.push('}'); - } } } else { result.push(ch); @@ -276,6 +279,31 @@ mod tests { use super::*; use std::env; + #[test] + fn test_replace_placeholders_supports_custom_variables() { + unsafe { + env::set_var("OSTOOL_TEST_CUSTOM_ENV", "env-value"); + } + + let result = replace_placeholders( + "workspace=${workspace}, package=${package}, env=${env:OSTOOL_TEST_CUSTOM_ENV}", + |placeholder| { + Ok(match placeholder { + "workspace" => Some("/tmp/workspace".into()), + "package" => Some("/tmp/workspace/kernel".into()), + p if p.starts_with("env:") => Some(env::var(&p[4..]).unwrap_or_default()), + _ => None, + }) + }, + ) + .unwrap(); + + assert_eq!( + result, + "workspace=/tmp/workspace, package=/tmp/workspace/kernel, env=env-value" + ); + } + #[test] fn test_replace_env_placeholders() { // 设置测试环境变量 @@ -340,6 +368,23 @@ mod tests { ); } + #[test] + fn test_replace_placeholders_keeps_unknown_and_legacy_placeholders() { + let result = replace_placeholders( + "${workspaceFolder}:${unknown}:${workspace}", + |placeholder| { + Ok(match placeholder { + "workspaceFolder" => Some("/legacy".into()), + "workspace" => Some("/modern".into()), + _ => None, + }) + }, + ) + .unwrap(); + + assert_eq!(result, "/legacy:${unknown}:/modern"); + } + #[test] fn test_real_env_vars() { // 测试真实的环境变量(如果存在)