From 6e77809bd90383c0e20d0bf3c26d01da8d366e02 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Sun, 9 Aug 2026 15:07:47 +0300 Subject: [PATCH 1/4] feat: support Terraform path references --- src/executor/engine.rs | 130 +++++++++++++++++++++++++++++++++- src/main.rs | 64 ++++++++++++++--- tests/path_references_test.rs | 54 ++++++++++++++ 3 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 tests/path_references_test.rs diff --git a/src/executor/engine.rs b/src/executor/engine.rs index f6169af..5ec915e 100644 --- a/src/executor/engine.rs +++ b/src/executor/engine.rs @@ -155,16 +155,65 @@ fn format_elapsed(secs: u64) -> String { pub struct ResourceEngine { provider_manager: Arc, parallelism: usize, + path_module: String, + path_root: String, + path_cwd: String, } impl ResourceEngine { pub fn new(provider_manager: Arc, parallelism: usize) -> Self { + let cwd = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + + Self::with_paths(provider_manager, parallelism, cwd.clone(), cwd.clone(), cwd) + } + + /// Create an engine with Terraform-compatible path expression values. + pub fn with_paths( + provider_manager: Arc, + parallelism: usize, + path_module: String, + path_root: String, + path_cwd: String, + ) -> Self { Self { provider_manager, parallelism, + path_module, + path_root, + path_cwd, } } + fn context_with_states( + &self, + var_defaults: HashMap, + resource_states: Arc>, + workspace_name: &str, + ) -> EvalContext { + EvalContext::with_states(var_defaults, resource_states).with_paths( + self.path_module.clone(), + self.path_root.clone(), + self.path_cwd.clone(), + workspace_name.to_string(), + ) + } + + fn context_plan_only( + &self, + var_defaults: HashMap, + workspace_name: &str, + ) -> EvalContext { + EvalContext::plan_only(var_defaults).with_paths( + self.path_module.clone(), + self.path_root.clone(), + self.path_cwd.clone(), + workspace_name.to_string(), + ) + } + /// Get a reference to the provider manager. pub fn provider_manager(&self) -> &ProviderManager { &self.provider_manager @@ -296,9 +345,10 @@ impl ResourceEngine { ); // Build eval context with count.index / each.key + existing resource states - let mut eval_ctx = EvalContext::with_states( + let mut eval_ctx = self.context_with_states( var_defaults.clone(), Arc::clone(&resource_states), + workspace_id, ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -474,9 +524,10 @@ impl ResourceEngine { planned_count, total_resources, ); - let mut ds_eval_ctx = EvalContext::with_states( + let mut ds_eval_ctx = self.context_with_states( var_defaults.clone(), Arc::clone(&resource_states), + workspace_id, ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -642,6 +693,9 @@ impl ResourceEngine { let pm = Arc::clone(&self.provider_manager); let ws_id = workspace_id.to_string(); let backend_clone = Arc::clone(&backend); + let path_module = self.path_module.clone(); + let path_root = self.path_root.clone(); + let path_cwd = self.path_cwd.clone(); // Shared map of completed resource states for cross-resource reference resolution. // As each resource completes, its new state is inserted here so dependents can // resolve references like `aws_s3_bucket.public_scripts.id`. @@ -667,6 +721,9 @@ impl ResourceEngine { let resource_states = Arc::clone(&resource_states); let var_defaults = var_defaults.clone(); let changed_addresses = Arc::clone(&changed_addresses); + let path_module = path_module.clone(); + let path_root = path_root.clone(); + let path_cwd = path_cwd.clone(); Box::pin(async move { match node { @@ -681,6 +738,12 @@ impl ResourceEngine { let mut eval_ctx = EvalContext::with_states( var_defaults.clone(), Arc::clone(&resource_states), + ) + .with_paths( + path_module.clone(), + path_root.clone(), + path_cwd.clone(), + ws_id.clone(), ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -921,6 +984,12 @@ impl ResourceEngine { let mut eval_ctx = EvalContext::with_states( var_defaults.clone(), Arc::clone(&resource_states), + ) + .with_paths( + path_module.clone(), + path_root.clone(), + path_cwd.clone(), + ws_id.clone(), ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -1046,6 +1115,9 @@ impl ResourceEngine { let pm = Arc::clone(&self.provider_manager); let ws_id = workspace_id.to_string(); let backend_clone = Arc::clone(&backend); + let path_module = self.path_module.clone(); + let path_root = self.path_root.clone(); + let path_cwd = self.path_cwd.clone(); self.initialize_providers(workspace).await?; @@ -1054,6 +1126,9 @@ impl ResourceEngine { let ws_id = ws_id.clone(); let backend = Arc::clone(&backend_clone); let var_defaults = var_defaults.clone(); + let path_module = path_module.clone(); + let path_root = path_root.clone(); + let path_cwd = path_cwd.clone(); Box::pin(async move { match node { @@ -1065,7 +1140,12 @@ impl ResourceEngine { ref index, .. } => { - let mut eval_ctx = EvalContext::plan_only(var_defaults.clone()); + let mut eval_ctx = EvalContext::plan_only(var_defaults.clone()).with_paths( + path_module.clone(), + path_root.clone(), + path_cwd.clone(), + ws_id.clone(), + ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { eval_ctx.count_index = Some(*i); @@ -1301,6 +1381,14 @@ pub struct EvalContext { pub each_key: Option, /// Current for_each value. pub each_value: Option, + /// Absolute directory of the current root module. + pub path_module: String, + /// Absolute directory of the root module. + pub path_root: String, + /// Absolute directory from which oxid was invoked. + pub path_cwd: String, + /// Current Terraform workspace name. + pub workspace_name: String, } impl EvalContext { @@ -1311,6 +1399,10 @@ impl EvalContext { count_index: None, each_key: None, each_value: None, + path_module: String::new(), + path_root: String::new(), + path_cwd: String::new(), + workspace_name: "default".to_string(), } } @@ -1324,8 +1416,26 @@ impl EvalContext { count_index: None, each_key: None, each_value: None, + path_module: String::new(), + path_root: String::new(), + path_cwd: String::new(), + workspace_name: "default".to_string(), } } + + pub fn with_paths( + mut self, + path_module: String, + path_root: String, + path_cwd: String, + workspace_name: String, + ) -> Self { + self.path_module = path_module; + self.path_root = path_root; + self.path_cwd = path_cwd; + self.workspace_name = workspace_name; + self + } } /// Convert attribute expressions to a JSON object, resolving variable and resource references. @@ -1818,6 +1928,20 @@ fn resolve_reference(parts: &[String], ctx: &EvalContext) -> serde_json::Value { } } + // Terraform path and workspace references. + if parts.len() == 2 && parts[0] == "path" { + return match parts[1].as_str() { + "module" => serde_json::Value::String(ctx.path_module.clone()), + "root" => serde_json::Value::String(ctx.path_root.clone()), + "cwd" => serde_json::Value::String(ctx.path_cwd.clone()), + _ => serde_json::Value::Null, + }; + } + + if parts.len() == 2 && parts[0] == "terraform" && parts[1] == "workspace" { + return serde_json::Value::String(ctx.workspace_name.clone()); + } + // data.TYPE.NAME.ATTR if parts.len() >= 4 && parts[0] == "data" { let address = format!("data.{}.{}", parts[1], parts[2]); diff --git a/src/main.rs b/src/main.rs index f85ed02..525ed10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; /// Reset SIGPIPE to default behavior so piping (e.g. `oxid graph | dot`) exits cleanly @@ -458,6 +458,35 @@ fn provider_manager(working_dir: &str) -> ProviderManager { ProviderManager::new(cache_dir) } +fn resolve_module_dir(config: &str) -> Result { + let config_path = std::fs::canonicalize(config) + .with_context(|| format!("Failed to resolve configuration path: {}", config))?; + + if config_path.is_file() { + config_path + .parent() + .map(Path::to_path_buf) + .context("Configuration file has no parent directory") + } else { + Ok(config_path) + } +} + +fn engine_for_cli(cli: &Cli, provider_manager: Arc) -> Result { + let module_dir = resolve_module_dir(&cli.config)?; + let cwd = std::env::current_dir().context("Failed to resolve current working directory")?; + let module_dir = module_dir.to_string_lossy().into_owned(); + let cwd = cwd.to_string_lossy().into_owned(); + + Ok(ResourceEngine::with_paths( + provider_manager, + cli.parallelism, + module_dir.clone(), + module_dir, + cwd, + )) +} + // ─── Commands ──────────────────────────────────────────────────────────────── async fn cmd_init(cli: &Cli) -> Result<()> { @@ -703,7 +732,7 @@ async fn cmd_plan( .context("No default workspace. Run 'oxid init' first.")?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let engine = engine_for_cli(cli, pm)?; let plan = engine .plan(&workspace, &*backend, &ws.id, refresh, targets, destroy) @@ -772,7 +801,14 @@ async fn cmd_apply(cli: &Cli, targets: &[String], auto_approve: bool) -> Result< .context("No default workspace. Run 'oxid init' first.")?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let module_dir = resolve_module_dir(&cli.config)? + .to_string_lossy() + .into_owned(); + let cwd = std::env::current_dir() + .context("Failed to resolve current working directory")? + .to_string_lossy() + .into_owned(); + let engine = engine_for_cli(cli, pm)?; // Plan first let plan = engine @@ -787,7 +823,7 @@ async fn cmd_apply(cli: &Cli, targets: &[String], auto_approve: bool) -> Result< // Still store outputs even when no resource changes if !workspace.outputs.is_empty() { let backend_arc: Arc = Arc::from(backend); - store_outputs(&workspace, &backend_arc, &ws.id).await; + store_outputs(&workspace, &backend_arc, &ws.id, &module_dir, &cwd).await; } return Ok(()); @@ -845,7 +881,7 @@ async fn cmd_apply(cli: &Cli, targets: &[String], auto_approve: bool) -> Result< // Evaluate and print outputs if !workspace.outputs.is_empty() && summary.failed == 0 { - store_outputs(&workspace, &backend_arc, &ws.id).await; + store_outputs(&workspace, &backend_arc, &ws.id, &module_dir, &cwd).await; } Ok(()) @@ -855,6 +891,8 @@ async fn store_outputs( workspace: &config::types::WorkspaceConfig, backend: &Arc, workspace_id: &str, + path_module: &str, + path_cwd: &str, ) { let resource_states: Arc> = Arc::new(dashmap::DashMap::new()); @@ -874,7 +912,13 @@ async fn store_outputs( let var_defaults = executor::engine::build_variable_defaults(workspace); let eval_ctx = - executor::engine::EvalContext::with_states(var_defaults, Arc::clone(&resource_states)); + executor::engine::EvalContext::with_states(var_defaults, Arc::clone(&resource_states)) + .with_paths( + path_module.to_string(), + path_module.to_string(), + path_cwd.to_string(), + workspace_id.to_string(), + ); println!(); println!("{}:", "Outputs".bold()); @@ -1058,7 +1102,7 @@ async fn cmd_destroy(cli: &Cli, _targets: &[String], auto_approve: bool) -> Resu } let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let engine = engine_for_cli(cli, pm)?; let run_id = backend .start_run(&ws.id, "destroy", resource_count as i32) @@ -1288,7 +1332,7 @@ async fn cmd_import(cli: &Cli, command: &ImportCommands) -> Result<()> { ))?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let engine = engine_for_cli(cli, pm)?; // Use the provider's ImportResourceState RPC // For now, create a resource state entry with the provider ID @@ -2013,7 +2057,7 @@ async fn cmd_blast_radius( .await? .context("No default workspace. Run 'oxid init' first.")?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let engine = engine_for_cli(cli, pm)?; let plan = engine .plan(&workspace, &**b, &ws.id, true, &[], false) .await?; @@ -2366,7 +2410,7 @@ async fn cmd_drift(cli: &Cli, refresh: bool) -> Result<()> { if refresh { println!("{}", "Refreshing state from providers...".dimmed()); let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = ResourceEngine::new(pm, cli.parallelism); + let engine = engine_for_cli(cli, pm)?; // Initialize and configure providers (connect, get schema, configure with region/creds) engine.initialize_providers(&workspace).await?; diff --git a/tests/path_references_test.rs b/tests/path_references_test.rs new file mode 100644 index 0000000..cdbacee --- /dev/null +++ b/tests/path_references_test.rs @@ -0,0 +1,54 @@ +use oxid::config::types::Expression; +use oxid::executor::engine::{eval_expression, EvalContext}; + +fn reference(parts: &[&str]) -> Expression { + Expression::Reference(parts.iter().map(|part| (*part).to_string()).collect()) +} + +#[test] +fn resolves_terraform_path_references() { + let ctx = EvalContext::plan_only(std::collections::HashMap::new()).with_paths( + "/workspace/module".to_string(), + "/workspace/root".to_string(), + "/workspace".to_string(), + "staging".to_string(), + ); + + assert_eq!( + eval_expression(&reference(&["path", "module"]), &ctx), + serde_json::json!("/workspace/module") + ); + assert_eq!( + eval_expression(&reference(&["path", "root"]), &ctx), + serde_json::json!("/workspace/root") + ); + assert_eq!( + eval_expression(&reference(&["path", "cwd"]), &ctx), + serde_json::json!("/workspace") + ); +} + +#[test] +fn resolves_terraform_workspace_reference() { + let ctx = EvalContext::plan_only(std::collections::HashMap::new()).with_paths( + "/workspace/module".to_string(), + "/workspace/root".to_string(), + "/workspace".to_string(), + "staging".to_string(), + ); + + assert_eq!( + eval_expression(&reference(&["terraform", "workspace"]), &ctx), + serde_json::json!("staging") + ); +} + +#[test] +fn missing_path_reference_remains_null() { + let ctx = EvalContext::plan_only(std::collections::HashMap::new()); + + assert_eq!( + eval_expression(&reference(&["path", "unknown"]), &ctx), + serde_json::Value::Null + ); +} From 696743cc2a0543c8a46342992acabe4ff933582b Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Thu, 17 Sep 2026 13:13:46 +0000 Subject: [PATCH 2/4] fix: resolve terraform.workspace to the workspace name The CLI passed the state store workspace id, a generated UUID, as the terraform.workspace value, so expressions saw the UUID instead of the workspace name that Terraform returns (for example "default"). Keep the workspace name on the engine next to the path values and use it when evaluating plan, apply, destroy, and output expressions. State reads and writes still use the workspace id. Add a CLI regression test that applies a configuration whose output is terraform.workspace and checks the stored value. --- src/executor/engine.rs | 51 +++++++++++++++++++---------------- src/main.rs | 42 ++++++++++++++++++++++------- tests/path_references_test.rs | 30 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 33 deletions(-) diff --git a/src/executor/engine.rs b/src/executor/engine.rs index 5ec915e..d3edc88 100644 --- a/src/executor/engine.rs +++ b/src/executor/engine.rs @@ -158,6 +158,7 @@ pub struct ResourceEngine { path_module: String, path_root: String, path_cwd: String, + workspace_name: String, } impl ResourceEngine { @@ -167,16 +168,26 @@ impl ResourceEngine { .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - Self::with_paths(provider_manager, parallelism, cwd.clone(), cwd.clone(), cwd) + Self::with_paths( + provider_manager, + parallelism, + cwd.clone(), + cwd.clone(), + cwd, + "default".to_string(), + ) } - /// Create an engine with Terraform-compatible path expression values. + /// Create an engine with Terraform-compatible path and workspace expression values. + /// + /// `workspace_name` is the workspace name (such as `default`), not its state store id. pub fn with_paths( provider_manager: Arc, parallelism: usize, path_module: String, path_root: String, path_cwd: String, + workspace_name: String, ) -> Self { Self { provider_manager, @@ -184,6 +195,7 @@ impl ResourceEngine { path_module, path_root, path_cwd, + workspace_name, } } @@ -191,26 +203,21 @@ impl ResourceEngine { &self, var_defaults: HashMap, resource_states: Arc>, - workspace_name: &str, ) -> EvalContext { EvalContext::with_states(var_defaults, resource_states).with_paths( self.path_module.clone(), self.path_root.clone(), self.path_cwd.clone(), - workspace_name.to_string(), + self.workspace_name.clone(), ) } - fn context_plan_only( - &self, - var_defaults: HashMap, - workspace_name: &str, - ) -> EvalContext { + fn context_plan_only(&self, var_defaults: HashMap) -> EvalContext { EvalContext::plan_only(var_defaults).with_paths( self.path_module.clone(), self.path_root.clone(), self.path_cwd.clone(), - workspace_name.to_string(), + self.workspace_name.clone(), ) } @@ -345,11 +352,8 @@ impl ResourceEngine { ); // Build eval context with count.index / each.key + existing resource states - let mut eval_ctx = self.context_with_states( - var_defaults.clone(), - Arc::clone(&resource_states), - workspace_id, - ); + let mut eval_ctx = self + .context_with_states(var_defaults.clone(), Arc::clone(&resource_states)); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { eval_ctx.count_index = Some(*i) @@ -524,11 +528,8 @@ impl ResourceEngine { planned_count, total_resources, ); - let mut ds_eval_ctx = self.context_with_states( - var_defaults.clone(), - Arc::clone(&resource_states), - workspace_id, - ); + let mut ds_eval_ctx = self + .context_with_states(var_defaults.clone(), Arc::clone(&resource_states)); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { ds_eval_ctx.count_index = Some(*i); @@ -696,6 +697,7 @@ impl ResourceEngine { let path_module = self.path_module.clone(); let path_root = self.path_root.clone(); let path_cwd = self.path_cwd.clone(); + let workspace_name = self.workspace_name.clone(); // Shared map of completed resource states for cross-resource reference resolution. // As each resource completes, its new state is inserted here so dependents can // resolve references like `aws_s3_bucket.public_scripts.id`. @@ -724,6 +726,7 @@ impl ResourceEngine { let path_module = path_module.clone(); let path_root = path_root.clone(); let path_cwd = path_cwd.clone(); + let workspace_name = workspace_name.clone(); Box::pin(async move { match node { @@ -743,7 +746,7 @@ impl ResourceEngine { path_module.clone(), path_root.clone(), path_cwd.clone(), - ws_id.clone(), + workspace_name.clone(), ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -989,7 +992,7 @@ impl ResourceEngine { path_module.clone(), path_root.clone(), path_cwd.clone(), - ws_id.clone(), + workspace_name.clone(), ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { @@ -1118,6 +1121,7 @@ impl ResourceEngine { let path_module = self.path_module.clone(); let path_root = self.path_root.clone(); let path_cwd = self.path_cwd.clone(); + let workspace_name = self.workspace_name.clone(); self.initialize_providers(workspace).await?; @@ -1129,6 +1133,7 @@ impl ResourceEngine { let path_module = path_module.clone(); let path_root = path_root.clone(); let path_cwd = path_cwd.clone(); + let workspace_name = workspace_name.clone(); Box::pin(async move { match node { @@ -1144,7 +1149,7 @@ impl ResourceEngine { path_module.clone(), path_root.clone(), path_cwd.clone(), - ws_id.clone(), + workspace_name.clone(), ); match index { Some(crate::config::types::ResourceIndex::Count(i)) => { diff --git a/src/main.rs b/src/main.rs index 525ed10..f42ef9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -472,7 +472,11 @@ fn resolve_module_dir(config: &str) -> Result { } } -fn engine_for_cli(cli: &Cli, provider_manager: Arc) -> Result { +fn engine_for_cli( + cli: &Cli, + provider_manager: Arc, + workspace_name: &str, +) -> Result { let module_dir = resolve_module_dir(&cli.config)?; let cwd = std::env::current_dir().context("Failed to resolve current working directory")?; let module_dir = module_dir.to_string_lossy().into_owned(); @@ -484,6 +488,7 @@ fn engine_for_cli(cli: &Cli, provider_manager: Arc) -> Result Result< .context("Failed to resolve current working directory")? .to_string_lossy() .into_owned(); - let engine = engine_for_cli(cli, pm)?; + let engine = engine_for_cli(cli, pm, &ws.name)?; // Plan first let plan = engine @@ -823,7 +828,15 @@ async fn cmd_apply(cli: &Cli, targets: &[String], auto_approve: bool) -> Result< // Still store outputs even when no resource changes if !workspace.outputs.is_empty() { let backend_arc: Arc = Arc::from(backend); - store_outputs(&workspace, &backend_arc, &ws.id, &module_dir, &cwd).await; + store_outputs( + &workspace, + &backend_arc, + &ws.id, + &ws.name, + &module_dir, + &cwd, + ) + .await; } return Ok(()); @@ -881,7 +894,15 @@ async fn cmd_apply(cli: &Cli, targets: &[String], auto_approve: bool) -> Result< // Evaluate and print outputs if !workspace.outputs.is_empty() && summary.failed == 0 { - store_outputs(&workspace, &backend_arc, &ws.id, &module_dir, &cwd).await; + store_outputs( + &workspace, + &backend_arc, + &ws.id, + &ws.name, + &module_dir, + &cwd, + ) + .await; } Ok(()) @@ -891,6 +912,7 @@ async fn store_outputs( workspace: &config::types::WorkspaceConfig, backend: &Arc, workspace_id: &str, + workspace_name: &str, path_module: &str, path_cwd: &str, ) { @@ -917,7 +939,7 @@ async fn store_outputs( path_module.to_string(), path_module.to_string(), path_cwd.to_string(), - workspace_id.to_string(), + workspace_name.to_string(), ); println!(); @@ -1102,7 +1124,7 @@ async fn cmd_destroy(cli: &Cli, _targets: &[String], auto_approve: bool) -> Resu } let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = engine_for_cli(cli, pm)?; + let engine = engine_for_cli(cli, pm, &ws.name)?; let run_id = backend .start_run(&ws.id, "destroy", resource_count as i32) @@ -1332,7 +1354,7 @@ async fn cmd_import(cli: &Cli, command: &ImportCommands) -> Result<()> { ))?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = engine_for_cli(cli, pm)?; + let engine = engine_for_cli(cli, pm, &ws.name)?; // Use the provider's ImportResourceState RPC // For now, create a resource state entry with the provider ID @@ -2057,7 +2079,7 @@ async fn cmd_blast_radius( .await? .context("No default workspace. Run 'oxid init' first.")?; let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = engine_for_cli(cli, pm)?; + let engine = engine_for_cli(cli, pm, &ws.name)?; let plan = engine .plan(&workspace, &**b, &ws.id, true, &[], false) .await?; @@ -2410,7 +2432,7 @@ async fn cmd_drift(cli: &Cli, refresh: bool) -> Result<()> { if refresh { println!("{}", "Refreshing state from providers...".dimmed()); let pm = Arc::new(provider_manager(&cli.working_dir)); - let engine = engine_for_cli(cli, pm)?; + let engine = engine_for_cli(cli, pm, &ws.name)?; // Initialize and configure providers (connect, get schema, configure with region/creds) engine.initialize_providers(&workspace).await?; diff --git a/tests/path_references_test.rs b/tests/path_references_test.rs index cdbacee..2b889fd 100644 --- a/tests/path_references_test.rs +++ b/tests/path_references_test.rs @@ -52,3 +52,33 @@ fn missing_path_reference_remains_null() { serde_json::Value::Null ); } + +#[test] +fn cli_outputs_resolve_terraform_workspace_to_workspace_name() { + let config_dir = tempfile::TempDir::new().unwrap(); + let work_dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + config_dir.path().join("main.tf"), + "output \"workspace\" {\n value = terraform.workspace\n}\n", + ) + .unwrap(); + + let oxid = |args: &[&str]| { + let mut cmd = assert_cmd::cargo_bin_cmd!("oxid"); + cmd.arg("-c") + .arg(config_dir.path()) + .arg("-w") + .arg(work_dir.path()) + .args(args) + .env("NO_COLOR", "1"); + cmd + }; + + oxid(&["init"]).assert().success(); + oxid(&["apply", "--auto-approve"]).assert().success(); + + // The state store keys workspaces by a generated id; Terraform exposes the name. + let output = oxid(&["output", "workspace"]).assert().success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert_eq!(stdout.trim(), "default"); +} From 4a6fd1def997ebfea9273ff3d755d5d7b5da06b1 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Sat, 26 Sep 2026 13:02:17 +0300 Subject: [PATCH 3/4] Fix clippy for_kv_map lint in provider download loop --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index f42ef9f..b8f0430 100644 --- a/src/main.rs +++ b/src/main.rs @@ -552,7 +552,7 @@ async fn cmd_init(cli: &Cli) -> Result<()> { } } - for (_name, (source, version_constraint)) in &providers_to_download { + for (source, version_constraint) in providers_to_download.values() { let version = version_constraint.as_deref().unwrap_or(">= 0.0.0"); tracing::info!( provider = %source, From e526b1e6cdbc95becb737d999d5440a695416773 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq Date: Thu, 22 Oct 2026 11:18:51 +0300 Subject: [PATCH 4/4] fix: bump async-trait to 0.1.92 to resolve the clippy double_must_use lint async-trait 0.1.89 generates a #[must_use] on its boxed futures, which newer clippy rejects (dtolnay/async-trait#303); 0.1.92 drops it. Only the lockfile changes; syn 3 is added because async-trait 0.1.91 moved to it. --- Cargo.lock | 75 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d8bf89..43c86ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,18 +154,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -619,7 +619,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -910,7 +910,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1193,7 +1193,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1234,7 +1234,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1515,7 +1515,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2652,7 +2652,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2783,7 +2783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2821,7 +2821,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.117", "tempfile", ] @@ -2835,7 +2835,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3366,7 +3366,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3616,7 +3616,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -3639,7 +3639,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -3805,6 +3805,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3822,7 +3833,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3881,7 +3892,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3892,7 +3903,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3985,7 +3996,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4084,7 +4095,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4185,7 +4196,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4471,7 +4482,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4587,7 +4598,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4598,7 +4609,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4895,7 +4906,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4911,7 +4922,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5003,7 +5014,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5024,7 +5035,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5044,7 +5055,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5065,7 +5076,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5098,7 +5109,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]]