Skip to main content

noetl_executor/
tools_bridge.rs

1//! Bridge from the CLI's YAML-parsed [`crate::playbook::Tool`] enum
2//! onto the [`noetl_tools`] registry's dispatch API.
3//!
4//! Added in R-1.1 PR-2c-1 per § H.10.4 of Appendix H of the global
5//! hybrid cloud blueprint; fleshed out with adapter helpers in
6//! R-1.1 PR-2c-2.  This module is the integration surface between
7//! the CLI's parsed playbook and the shared tool registry the
8//! worker (R-1.3) also uses.
9//!
10//! ## Strategy B rollout
11//!
12//! Replacement of the CLI's inline tool implementations happens
13//! incrementally — one tool kind per sub-PR (PR-2c-3 rhai, PR-2c-4
14//! shell, PR-2c-5 http, PR-2c-6 duckdb, PR-2c-7 playbook, PR-2c-8
15//! auth + sink).  This module ships the adapter layer in PR-2c-2;
16//! each subsequent sub-PR fills in one [`dispatch_via_registry`]
17//! match arm and replaces the matching CLI call site in
18//! `repos/cli/src/playbook_runner.rs`.
19//!
20//! ## Why a bridge instead of converting the Tool enum directly
21//!
22//! The CLI's [`crate::playbook::Tool`] enum and the registry's
23//! [`noetl_tools::registry::ToolConfig`] carry different invariants:
24//!
25//! - The CLI's `Tool::Auth { provider, scopes, project }` resolves
26//!   credentials inline during dispatch.  The worker resolves them at
27//!   credential-resolution time (before tool dispatch).  The bridge
28//!   needs to know which mode to use; it's not a trivial enum cast.
29//! - The CLI's `Tool::Sink { target, format }` writes outputs through
30//!   the runner's filesystem helpers.  The registry would dispatch
31//!   sinks through the same `noetl-tools` registry, but the tool kind
32//!   doesn't exist on the worker side yet (PR-2c-8 may add it).
33//! - The CLI's `Tool::DuckDb { db, query, params }` opens a fresh
34//!   DuckDB connection per call.  `noetl-tools::tools::duckdb`
35//!   manages a pool.  Semantic difference; needs careful migration.
36//!
37//! Keeping the bridge explicit forces these decisions into one place
38//! instead of scattering them across each tool-kind sub-PR.
39
40#![allow(dead_code)] // until PR-2c-4 onwards wires the call sites in.
41
42use std::collections::HashMap;
43
44use anyhow::Result;
45use noetl_tools::auth::GcpAuth;
46use noetl_tools::context::ExecutionContext as ToolsExecutionContext;
47use noetl_tools::registry::{Tool as ToolsRegistryTool, ToolConfig};
48use noetl_tools::result::{ToolResult, ToolStatus};
49use noetl_tools::tools::{DuckdbTool, HttpTool, RhaiTool, ShellTool};
50
51use crate::playbook::{AuthConfig as CliAuthConfig, CmdsList, SinkFormat, Tool};
52
53// ---------------------------------------------------------------------------
54// Bridge outcome — what the dispatch returns back to the caller.
55// ---------------------------------------------------------------------------
56
57/// Outcome of a bridged tool dispatch.
58///
59/// The shape matches the existing CLI surface where
60/// `PlaybookRunner::execute_tool` returns `Result<Option<String>>`:
61/// `result == Some(s)` for a successful tool execution that produced
62/// output the runner stores in `step_results[step].result`; `None`
63/// for tools that do not produce a per-step string result (e.g.
64/// fire-and-forget sinks).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct BridgeOutcome {
67    pub result: Option<String>,
68}
69
70impl BridgeOutcome {
71    pub fn empty() -> Self {
72        Self { result: None }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Bridge context — what the dispatch needs from the caller.
78// ---------------------------------------------------------------------------
79
80/// Per-call context for the bridge.  Groups together what would
81/// otherwise be many parameters threaded through every dispatch site.
82///
83/// The CLI's `ExecutionContext` (`repos/cli/src/playbook_runner.rs`)
84/// has a different shape than [`ToolsExecutionContext`] — the CLI
85/// uses `HashMap<String, String>` for variables and tracks step
86/// results separately; `noetl-tools` uses `HashMap<String,
87/// serde_json::Value>` and bundles many more execution-level fields
88/// (server_url, worker_id, command_id, etc.).
89///
90/// `BridgeContext` is the narrow view the CLI hands to the bridge;
91/// [`to_tools_context`] expands it into the full
92/// [`ToolsExecutionContext`] shape.
93pub struct BridgeContext<'a> {
94    /// Execution id — required by [`ToolsExecutionContext`].  CLI
95    /// local mode synthesises this from the start time / playbook
96    /// path; the worker uses the snowflake id from `noetl.command`.
97    pub execution_id: i64,
98
99    /// Step name the bridged tool is running under.
100    pub step: &'a str,
101
102    /// CLI variables map (workload.*, vars.*, <step>.result, etc.).
103    pub variables: &'a HashMap<String, String>,
104
105    /// Control-plane server URL.  Empty string when running in
106    /// CLI local mode without a server backend.
107    pub server_url: String,
108
109    /// Worker id / command id — `None` in CLI local mode.
110    pub worker_id: Option<String>,
111    pub command_id: Option<String>,
112}
113
114// ---------------------------------------------------------------------------
115// Adapters
116// ---------------------------------------------------------------------------
117
118/// Convert a [`BridgeContext`] into the [`ToolsExecutionContext`]
119/// shape `noetl-tools` tools expect.  String variables become
120/// [`serde_json::Value::String`] entries; secrets stay empty (CLI
121/// local mode resolves credentials at the credential-resolver layer,
122/// not at tool dispatch).
123///
124/// Variable shape: **flat**.  Each CLI variable `workload.region`
125/// becomes a JSON value at the same flat key in the resulting map.
126/// This matches what most `noetl-tools` tools (http / postgres / etc.)
127/// expect from their template engine.  The rhai tool needs a
128/// *nested* shape so `workload.region` is reachable as a Rhai field
129/// access on a `workload` map; see [`to_tools_context_for_rhai`] for
130/// the restructured variant used inside the rhai dispatch arm.
131pub fn to_tools_context(bridge: &BridgeContext) -> ToolsExecutionContext {
132    let variables: HashMap<String, serde_json::Value> = bridge
133        .variables
134        .iter()
135        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
136        .collect();
137
138    ToolsExecutionContext {
139        execution_id: bridge.execution_id,
140        step: bridge.step.to_string(),
141        variables,
142        server_url: bridge.server_url.clone(),
143        worker_id: bridge.worker_id.clone(),
144        command_id: bridge.command_id.clone(),
145        ..ToolsExecutionContext::default()
146    }
147}
148
149/// Build a [`ToolsExecutionContext`] whose `variables` map matches the
150/// scope shape the CLI's inline `execute_rhai_script` produced — flat
151/// `workload.region` / `vars.x` / `<step>.<field>` keys grouped into
152/// nested objects so Rhai's `workload.region` / `vars.x` / `<step>.<field>`
153/// field-access syntax works.
154///
155/// PR-2c-3 introduces this for the rhai dispatch arm.  Other tool
156/// kinds (http, postgres, duckdb, etc.) continue to consume the flat
157/// shape from [`to_tools_context`] because their template engines
158/// expect the `{{workload.region}}` lookup style, not Rhai-style
159/// field navigation.
160pub fn to_tools_context_for_rhai(bridge: &BridgeContext) -> ToolsExecutionContext {
161    let mut variables: HashMap<String, serde_json::Value> = HashMap::new();
162    let mut workload_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
163    let mut vars_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
164    let mut step_maps: HashMap<String, serde_json::Map<String, serde_json::Value>> =
165        HashMap::new();
166
167    for (key, value) in bridge.variables {
168        let val = serde_json::Value::String(value.clone());
169        if let Some(suffix) = key.strip_prefix("workload.") {
170            workload_map.insert(suffix.to_string(), val);
171        } else if let Some(suffix) = key.strip_prefix("vars.") {
172            vars_map.insert(suffix.to_string(), val);
173        } else if let Some((step, field)) = key.split_once('.') {
174            step_maps
175                .entry(step.to_string())
176                .or_default()
177                .insert(field.to_string(), val);
178        } else {
179            // Unprefixed keys land at the top level — same shape as
180            // [`to_tools_context`].
181            variables.insert(key.clone(), val);
182        }
183    }
184
185    if !workload_map.is_empty() {
186        variables.insert(
187            "workload".to_string(),
188            serde_json::Value::Object(workload_map),
189        );
190    }
191    if !vars_map.is_empty() {
192        variables.insert("vars".to_string(), serde_json::Value::Object(vars_map));
193    }
194    for (step, map) in step_maps {
195        variables.insert(step, serde_json::Value::Object(map));
196    }
197
198    ToolsExecutionContext {
199        execution_id: bridge.execution_id,
200        step: bridge.step.to_string(),
201        variables,
202        server_url: bridge.server_url.clone(),
203        worker_id: bridge.worker_id.clone(),
204        command_id: bridge.command_id.clone(),
205        ..ToolsExecutionContext::default()
206    }
207}
208
209/// Build a [`ToolConfig`] from a CLI [`Tool`] enum variant.
210///
211/// The `kind` string matches what [`noetl_tools::registry::ToolRegistry`]
212/// uses for dispatch.  The `config` payload is the variant's fields
213/// serialized as JSON; the receiving tool deserializes its own
214/// expected schema from this value (e.g. `noetl_tools::tools::shell`
215/// expects `{"cmds": [...]}`).
216///
217/// `Tool::Unsupported` returns a `ToolConfig` with `kind: "unsupported"`
218/// — dispatch will fail at registry lookup, which matches the CLI's
219/// current behaviour of emitting an error.
220pub fn to_tools_config(tool: &Tool) -> ToolConfig {
221    let (kind, config) = match tool {
222        Tool::Shell { cmds } => {
223            // noetl-tools::ShellConfig expects a single `command`
224            // string.  CLI's CmdsList::Multiple becomes a newline-
225            // joined block (one bash invocation with a multi-line
226            // script); CmdsList::Single becomes the string verbatim.
227            //
228            // Important: this is the per-call ToolConfig shape.  The
229            // Tool::Shell arm of `dispatch_via_registry` does NOT use
230            // this helper because the CLI's runtime semantics require
231            // one bash invocation PER command (independent process,
232            // no shared cwd/env state) — the dispatch arm loops and
233            // builds per-command ToolConfigs via [`shell_command_config`].
234            (
235                "shell",
236                serde_json::json!({
237                    "command": match cmds {
238                        CmdsList::Single(s) => s.clone(),
239                        CmdsList::Multiple(v) => v.join("\n"),
240                    },
241                    "shell": "bash",
242                    "capture": true,
243                }),
244            )
245        }
246        Tool::Http {
247            method,
248            url,
249            headers,
250            params,
251            body,
252            auth: _, // resolved at dispatch time into a Bearer header; not threaded through ToolConfig.auth (see PR-2c-5)
253        } => (
254            "http",
255            // noetl-tools' HttpConfig deserializes the method via
256            // `#[serde(rename_all = "UPPERCASE")]`, so we emit the
257            // uppercased CLI string here.  The body is wrapped as a
258            // JSON Value: if the CLI's body parses as JSON we pass the
259            // parsed Value (so reqwest serialises it as JSON with the
260            // right Content-Type); otherwise we pass it as a JSON
261            // string which noetl-tools sends verbatim as the body.
262            serde_json::json!({
263                "method": method.to_uppercase(),
264                "url": url,
265                "headers": headers,
266                "params": params,
267                "body": body.as_deref().map(http_body_value),
268            }),
269        ),
270        Tool::Playbook { path, args, input } => (
271            "playbook",
272            serde_json::json!({
273                "path": path,
274                "args": args,
275                "input": input,
276            }),
277        ),
278        Tool::DuckDb { db, query, params } => (
279            // noetl-tools' DuckdbConfig schema uses `db_path` (not
280            // `db`), `query` is required (so we substitute an empty
281            // string when the CLI doesn't carry one — the dispatch
282            // arm short-circuits in that case), and params are
283            // `Vec<serde_json::Value>` rather than `Vec<String>`.
284            // Conversion is faithful: a CLI string param becomes a
285            // JSON string value bound at the `?` placeholder by
286            // noetl-tools' DuckdbTool.
287            //
288            // Compatibility note: the CLI's pre-PR-2c-6
289            // `execute_duckdb_query` accepted but **ignored** the
290            // `params` field (signature was `_params: &[String]`).
291            // The bridge now binds them, which is a feature gain
292            // documented in the PR body and on the executor-crate-
293            // architecture wiki page.
294            "duckdb",
295            serde_json::json!({
296                "db_path": db,
297                "query": query.clone().unwrap_or_default(),
298                "params": params
299                    .iter()
300                    .map(|p| serde_json::Value::String(p.clone()))
301                    .collect::<Vec<_>>(),
302                "as_objects": true,
303            }),
304        ),
305        Tool::Rhai { code, args } => (
306            "rhai",
307            serde_json::json!({
308                "code": code,
309                "args": args,
310            }),
311        ),
312        Tool::Auth { provider, scopes, project } => (
313            "auth",
314            serde_json::json!({
315                "provider": provider,
316                "scopes": scopes,
317                "project": project,
318            }),
319        ),
320        Tool::Sink { target, format } => (
321            "sink",
322            serde_json::json!({
323                "target": target_to_value(target),
324                "format": format!("{:?}", format).to_lowercase(),
325            }),
326        ),
327        Tool::Unsupported => ("unsupported", serde_json::json!({})),
328    };
329
330    ToolConfig {
331        kind: kind.to_string(),
332        config,
333        timeout: None,
334        retry: None,
335        auth: None,
336    }
337}
338
339/// Build a single-command ToolConfig for the shell tool.  Used by
340/// the `Tool::Shell` dispatch arm to preserve the CLI's per-command
341/// bash-invocation semantics (independent process, no shared
342/// cwd/env state across commands).
343fn shell_command_config(command: &str) -> ToolConfig {
344    ToolConfig {
345        kind: "shell".to_string(),
346        config: serde_json::json!({
347            "command": command,
348            "shell": "bash",
349            "capture": true,
350        }),
351        timeout: None,
352        retry: None,
353        auth: None,
354    }
355}
356
357/// Convert a CLI HTTP body string into a JSON [`serde_json::Value`]
358/// suitable for noetl-tools' `HttpConfig.body` field.  If the body
359/// parses as JSON, the parsed value is returned (and `reqwest` sends
360/// it with `Content-Type: application/json`).  Otherwise the body
361/// is wrapped as a [`Value::String`] which `reqwest` writes
362/// verbatim as the request body.
363fn http_body_value(body: &str) -> serde_json::Value {
364    serde_json::from_str(body).unwrap_or_else(|_| serde_json::Value::String(body.to_string()))
365}
366
367/// Resolve a CLI [`AuthConfig`] to a Bearer token using noetl-tools'
368/// [`GcpAuth`] provider.
369///
370/// CLI providers `"gcp"`, `"google"`, and `"adc"` all map to GCP
371/// Application Default Credentials.  Any other provider value
372/// returns an error matching the CLI's pre-PR-2c-5 behaviour.
373///
374/// This replaces the CLI's inline `get_auth_token` (which shelled
375/// out to `gcloud auth print-access-token`).  See semantic
376/// divergence row on the executor-crate-architecture wiki page.
377pub async fn resolve_auth_to_bearer(cfg: &CliAuthConfig) -> Result<String> {
378    match cfg.provider.as_str() {
379        "gcp" | "google" | "adc" => {
380            let gcp = GcpAuth::new();
381            let scopes: Vec<&str> = cfg.scopes.iter().map(|s| s.as_str()).collect();
382            let token = if scopes.is_empty() {
383                gcp.get_default_token()
384                    .await
385                    .map_err(|e| anyhow::anyhow!("failed to get GCP access token: {}", e))?
386            } else {
387                gcp.get_token(&scopes)
388                    .await
389                    .map_err(|e| anyhow::anyhow!("failed to get GCP access token: {}", e))?
390            };
391            Ok(token)
392        }
393        other => anyhow::bail!(
394            "unsupported auth provider: {}. Supported: gcp, google, adc",
395            other
396        ),
397    }
398}
399
400/// Build the noetl-tools [`ToolConfig`] for an HTTP request.
401///
402/// Identical to the [`to_tools_config`] `Tool::Http` arm but pulled
403/// out so the dispatch arm can also inject an `Authorization:
404/// Bearer <token>` header when a CLI `AuthConfig` is present
405/// (resolved via [`resolve_auth_to_bearer`]).
406///
407/// CLI's `auth` is intentionally NOT mapped to noetl-tools'
408/// `ToolConfig.auth` field: that field expects an `AuthConfig` with
409/// `credential` / `token` lookup against `ExecutionContext.secrets`,
410/// which CLI local mode does not populate.  Pre-resolving the
411/// token and injecting it as a header keeps the CLI's existing
412/// authority semantics (the CLI process's gcloud / ADC chain) and
413/// avoids reshaping the credential resolver path.
414fn http_tool_config(
415    method: &str,
416    url: &str,
417    headers: &HashMap<String, String>,
418    params: &HashMap<String, String>,
419    body: Option<&str>,
420    bearer: Option<&str>,
421) -> ToolConfig {
422    let mut merged_headers = headers.clone();
423    if let Some(token) = bearer {
424        merged_headers.insert(
425            "Authorization".to_string(),
426            format!("Bearer {}", token),
427        );
428    }
429    ToolConfig {
430        kind: "http".to_string(),
431        config: serde_json::json!({
432            "method": method.to_uppercase(),
433            "url": url,
434            "headers": merged_headers,
435            "params": params,
436            "body": body.map(http_body_value),
437        }),
438        timeout: None,
439        retry: None,
440        auth: None,
441    }
442}
443
444/// Reshape noetl-tools' HTTP result envelope back to the CLI's
445/// pre-PR-2c-5 shape.
446///
447/// noetl-tools' HttpTool always packs `data: {"status_code":
448/// u16, "headers": {...}, "body": <json>}` into the ToolResult,
449/// regardless of whether the HTTP response was 2xx (Success) or
450/// 4xx/5xx (Error).  The CLI's `execute_http_request` returned the
451/// envelope `{"status": <int>, "body": <json>}` for ALL HTTP
452/// responses (including 4xx/5xx) so playbook steps could branch on
453/// the status code.  We preserve that contract here: only network-
454/// transport failures bubble up as `anyhow::Error`; HTTP error
455/// statuses come back inside the JSON envelope.
456fn reshape_http_result(result: ToolResult) -> Result<BridgeOutcome> {
457    if let Some(data) = result.data {
458        let status_code = data
459            .get("status_code")
460            .and_then(|v| v.as_u64())
461            .unwrap_or(0) as i32;
462        let body = data
463            .get("body")
464            .cloned()
465            .unwrap_or(serde_json::Value::Null);
466        let envelope = serde_json::json!({
467            "status": status_code,
468            "body": body,
469        });
470        return Ok(BridgeOutcome {
471            result: Some(envelope.to_string()),
472        });
473    }
474    // No data — fall back to the generic from_tools_result path so
475    // we surface whatever error / stdout the tool emitted.
476    from_tools_result(result)
477}
478
479/// Build a [`ToolConfig`] for a DuckDB query.
480///
481/// Used by the `Tool::DuckDb` dispatch arm.  Path resolution
482/// (playbook-relative vs absolute) and `mkdir -p` of the parent
483/// directory are handled at the CLI call site BEFORE the bridge is
484/// invoked, so this helper receives an already-resolved absolute
485/// path string (or `:memory:` for in-memory mode).
486fn duckdb_tool_config(
487    db_path: &str,
488    query: &str,
489    params: &[String],
490) -> ToolConfig {
491    ToolConfig {
492        kind: "duckdb".to_string(),
493        config: serde_json::json!({
494            "db_path": db_path,
495            "query": query,
496            "params": params
497                .iter()
498                .map(|p| serde_json::Value::String(p.clone()))
499                .collect::<Vec<_>>(),
500            // CLI's pre-PR-2c-6 SELECT result shape was an array of
501            // JSON objects keyed by column name; `as_objects: true`
502            // matches that.  `reshape_duckdb_result` then unwraps
503            // the noetl-tools envelope back to the raw array.
504            "as_objects": true,
505        }),
506        timeout: None,
507        retry: None,
508        auth: None,
509    }
510}
511
512/// Reshape noetl-tools' DuckDB result envelope back to the CLI's
513/// pre-PR-2c-6 shape.
514///
515/// noetl-tools' DuckdbTool returns:
516/// - SELECT / WITH: `data: {"columns": [...], "rows": [{...}, ...],
517///   "row_count": N}`
518/// - non-SELECT:    `data: {"affected_rows": N}`
519///
520/// The CLI's `execute_duckdb_query` returned:
521/// - SELECT / WITH: a JSON array of objects (pretty-printed)
522/// - non-SELECT:    the literal string `{"status": "ok"}`
523///
524/// `reshape_duckdb_result` maps the former onto the latter so
525/// playbook steps that read `<step>.result[0].col_name` keep
526/// working.  `affected_rows` from the noetl-tools envelope is
527/// dropped on purpose — the CLI never exposed it.
528fn reshape_duckdb_result(result: ToolResult) -> Result<BridgeOutcome> {
529    let data = match result.data {
530        Some(d) => d,
531        None => return from_tools_result(result),
532    };
533
534    if let Some(rows) = data.get("rows").and_then(|v| v.as_array()) {
535        // SELECT path.  Return the rows array as a pretty-printed
536        // JSON string — matches the CLI's
537        // `serde_json::to_string_pretty(&results)`.
538        let pretty = serde_json::to_string_pretty(rows)?;
539        return Ok(BridgeOutcome { result: Some(pretty) });
540    }
541
542    if data.get("affected_rows").is_some() {
543        // Non-SELECT path.  CLI emitted the literal `{"status":
544        // "ok"}` here; preserve that.
545        return Ok(BridgeOutcome {
546            result: Some(r#"{"status": "ok"}"#.to_string()),
547        });
548    }
549
550    // Unknown shape — fall back to the generic from_tools_result
551    // path so we still surface whatever the tool emitted.
552    from_tools_result(ToolResult {
553        status: result.status,
554        data: Some(data),
555        error: result.error,
556        stdout: result.stdout,
557        stderr: result.stderr,
558        exit_code: result.exit_code,
559        duration_ms: result.duration_ms,
560    })
561}
562
563/// Prepare the variable map for a sub-playbook invocation.
564///
565/// Used by the CLI's `Tool::Playbook` arm (which keeps owning the
566/// tree-walker recursion per § H.10).  The helper merges the
567/// parent context's variables with the sub-playbook's
568/// `input:` (DSL v2) or `args:` (DSL v1 legacy), each rendered
569/// against the parent context via the caller-supplied
570/// `render_template` closure and prefixed with `workload.` to
571/// match the sub-playbook's expected variable shape.
572///
573/// `input` takes precedence over `args` when both are present —
574/// same precedence the CLI's pre-PR-2c-7 inline implementation
575/// applied.
576///
577/// `parent_vars`, `args`, and `input` correspond directly to the
578/// caller's `context.variables`, `Tool::Playbook.args`, and
579/// `Tool::Playbook.input` fields.  The `render` closure receives
580/// each template string and is expected to return the rendered
581/// value (the CLI passes `|t| self.render_template(t, context)`).
582///
583/// Returning a fresh `HashMap` rather than mutating in place makes
584/// the helper easy to test and matches how the inline
585/// implementation operated.
586pub fn prepare_sub_playbook_vars<F>(
587    parent_vars: &HashMap<String, String>,
588    args: &HashMap<String, String>,
589    input: &HashMap<String, serde_yaml::Value>,
590    mut render: F,
591) -> Result<HashMap<String, String>>
592where
593    F: FnMut(&str) -> Result<String>,
594{
595    let mut sub_vars = parent_vars.clone();
596
597    if !input.is_empty() {
598        // DSL v2: tool.input takes precedence — render and prefix
599        // with `workload.`.
600        for (key, value_yaml) in input {
601            let template = match value_yaml {
602                serde_yaml::Value::String(s) => s.clone(),
603                serde_yaml::Value::Number(n) => n.to_string(),
604                serde_yaml::Value::Bool(b) => b.to_string(),
605                other => serde_yaml::to_string(other)?.trim().to_string(),
606            };
607            let value = render(&template)?;
608            sub_vars.insert(format!("workload.{}", key), value);
609        }
610    } else if !args.is_empty() {
611        // DSL v1 legacy: args field — prefix with `workload.`.
612        for (key, template) in args {
613            let value = render(template)?;
614            sub_vars.insert(format!("workload.{}", key), value);
615        }
616    }
617
618    Ok(sub_vars)
619}
620
621/// Apply post-resolution `Tool::Auth` side-effects to the CLI's
622/// execution context.
623///
624/// Returns the (key, value) pairs the caller should
625/// `set_variable` on its `ExecutionContext` so subsequent steps
626/// can reference `{{ auth.token }}` etc.  Wrapping this in a
627/// helper means future call sites (the worker, integration tests)
628/// don't have to re-derive which keys to set.
629///
630/// `project` is the **already-rendered** project string (the CLI
631/// renders templates against its own context before calling this
632/// helper), or `None` if the playbook didn't supply one.
633///
634/// Output order:
635///  - `auth.project` (only if `project` is `Some` and non-empty)
636///  - `auth.token`
637///  - `auth.provider`
638///
639/// Matching the CLI's pre-PR-2c-8 ordering — `auth.project` set
640/// first by the inline arm, then the token + provider after the
641/// `resolve_auth_to_bearer` call.
642pub fn auth_context_updates(
643    provider: &str,
644    token: &str,
645    project: Option<&str>,
646) -> Vec<(String, String)> {
647    let mut updates: Vec<(String, String)> = Vec::with_capacity(3);
648    if let Some(p) = project {
649        if !p.is_empty() {
650            updates.push(("auth.project".to_string(), p.to_string()));
651        }
652    }
653    updates.push(("auth.token".to_string(), token.to_string()));
654    updates.push(("auth.provider".to_string(), provider.to_string()));
655    updates
656}
657
658/// Format the payload a `Tool::Sink` writes to its target.
659///
660/// Pure transformation lifted from the CLI's inline
661/// `Tool::Sink` arm.  The CLI passes the last step's result
662/// (already a JSON-serialized string in `ExecutionContext`) and
663/// the playbook's declared `format:` field; the helper returns
664/// the formatted string ready to write to file / DuckDB / GCS.
665///
666/// Format rules:
667/// - [`SinkFormat::Json`]: pass-through.  Same as CLI's
668///   pre-PR-2c-8 behaviour (the raw step-result string).
669/// - [`SinkFormat::Yaml`]: parse the input as JSON, then dump as
670///   YAML.  Falls back to pass-through if the input doesn't parse.
671/// - [`SinkFormat::Csv`]: see [`json_to_csv`] for the rules.
672pub fn format_sink_payload(format: &SinkFormat, raw: &str) -> Result<String> {
673    match format {
674        SinkFormat::Json => Ok(raw.to_string()),
675        SinkFormat::Yaml => {
676            if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(raw) {
677                Ok(serde_yaml::to_string(&json_val).unwrap_or_else(|_| raw.to_string()))
678            } else {
679                Ok(raw.to_string())
680            }
681        }
682        SinkFormat::Csv => json_to_csv(raw),
683    }
684}
685
686/// Convert a JSON-array-of-objects string into CSV.
687///
688/// Pure helper lifted from the CLI's inline `json_to_csv`.  Returns
689/// the input unchanged if:
690/// - it doesn't parse as JSON,
691/// - it parses as a non-array value, or
692/// - it's an empty array, or
693/// - the first element isn't a JSON object.
694///
695/// Otherwise: emits a header row from the first object's keys
696/// followed by one row per array element.  Values are converted
697/// via `Display`; strings that contain `,` or `"` are
698/// double-quoted with embedded `"` doubled — minimal RFC 4180
699/// quoting, matching the CLI's pre-PR-2c-8 implementation.
700pub fn json_to_csv(json_str: &str) -> Result<String> {
701    let value: serde_json::Value =
702        serde_json::from_str(json_str).unwrap_or(serde_json::Value::String(json_str.to_string()));
703
704    match value {
705        serde_json::Value::Array(arr) if !arr.is_empty() => {
706            let headers: Vec<String> = if let Some(serde_json::Value::Object(obj)) = arr.first() {
707                obj.keys().cloned().collect()
708            } else {
709                return Ok(json_str.to_string());
710            };
711
712            let mut csv = headers.join(",") + "\n";
713
714            for item in &arr {
715                if let serde_json::Value::Object(obj) = item {
716                    let row: Vec<String> = headers
717                        .iter()
718                        .map(|h| {
719                            obj.get(h)
720                                .map(|v| match v {
721                                    serde_json::Value::String(s) => {
722                                        if s.contains(',') || s.contains('"') {
723                                            format!("\"{}\"", s.replace('"', "\"\""))
724                                        } else {
725                                            s.clone()
726                                        }
727                                    }
728                                    _ => v.to_string(),
729                                })
730                                .unwrap_or_default()
731                        })
732                        .collect();
733                    csv.push_str(&row.join(","));
734                    csv.push('\n');
735                }
736            }
737            Ok(csv)
738        }
739        _ => Ok(json_str.to_string()),
740    }
741}
742
743fn target_to_value(target: &crate::playbook::SinkTarget) -> serde_json::Value {
744    match target {
745        crate::playbook::SinkTarget::File { path } => {
746            serde_json::json!({"type": "file", "path": path})
747        }
748        crate::playbook::SinkTarget::DuckDb { db, table } => {
749            serde_json::json!({"type": "duckdb", "db": db, "table": table})
750        }
751        crate::playbook::SinkTarget::Gcs { bucket, path } => {
752            serde_json::json!({"type": "gcs", "bucket": bucket, "path": path})
753        }
754    }
755}
756
757/// Convert a [`ToolResult`] back into the bridge outcome shape the
758/// CLI consumes.  Success results carry `data` (or `stdout` if no
759/// `data` was populated) as the result string; failures bubble up
760/// as `anyhow::Error` so the CLI's existing error-handling chain
761/// continues to work.
762pub fn from_tools_result(result: ToolResult) -> Result<BridgeOutcome> {
763    match result.status {
764        ToolStatus::Success => {
765            let payload = result
766                .data
767                .map(|v| match v {
768                    serde_json::Value::String(s) => s,
769                    other => other.to_string(),
770                })
771                .or(result.stdout);
772            Ok(BridgeOutcome { result: payload })
773        }
774        ToolStatus::Error => Err(anyhow::anyhow!(
775            "tool execution failed: {}",
776            result.error.unwrap_or_else(|| "unknown error".to_string())
777        )),
778        ToolStatus::Timeout => Err(anyhow::anyhow!(
779            "tool execution timed out after {} ms",
780            result.duration_ms.unwrap_or(0)
781        )),
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Dispatch — per-tool-kind match scaffold.
787// ---------------------------------------------------------------------------
788
789/// Bridge dispatch entry point.  Each tool kind is replaced
790/// incrementally in subsequent sub-PRs (PR-2c-3 onwards).
791///
792/// The function is async because every concrete `noetl-tools` tool
793/// implementation is async (`Tool::execute` is `async`).  The CLI
794/// adapts via `tokio::runtime::Handle::current().block_on(...)` if
795/// the call site is sync — see PR-2c-3's wiring for the pattern.
796pub async fn dispatch_via_registry(
797    tool: &Tool,
798    bridge: &BridgeContext<'_>,
799) -> Result<BridgeOutcome> {
800    let _config = to_tools_config(tool);
801    let _ctx = to_tools_context(bridge);
802
803    match tool {
804        Tool::Rhai { .. } => {
805            // PR-2c-3: first real tool replacement.  Builds a
806            // RhaiTool from noetl-tools, dispatches against the
807            // adapter-converted config + context, and converts the
808            // result back through `from_tools_result`.
809            //
810            // Semantic note documented in the PR body: noetl-tools'
811            // `timestamp()` returns the Unix epoch as a string
812            // (e.g. "1716847425"), whereas the CLI's inline
813            // implementation returned `chrono::Local::now()
814            // .format("%H:%M:%S")` (e.g. "14:23:45").  Other
815            // helpers (log, print, parse_json, contains, http_*,
816            // get_gcp_token, sleep, sleep_ms) match.
817            let rhai_tool = RhaiTool::new();
818            let config = to_tools_config(tool);
819            // rhai needs a nested variable shape so
820            // `workload.region` is a Rhai field-access expression.
821            let ctx = to_tools_context_for_rhai(bridge);
822            let result = rhai_tool
823                .execute(&config, &ctx)
824                .await
825                .map_err(|e| anyhow::anyhow!("rhai dispatch failed: {}", e))?;
826            from_tools_result(result)
827        }
828        Tool::Shell { cmds } => {
829            // PR-2c-4: dispatch through noetl_tools::ShellTool.
830            //
831            // CLI semantics preserved:
832            // - CmdsList::Single splits on newlines into individual
833            //   commands; each runs in its own bash invocation.
834            // - CmdsList::Multiple runs each element in its own
835            //   bash invocation in order.
836            // - Bails on first non-zero exit (CLI's existing
837            //   `anyhow::bail!("Command failed ...")`).
838            // - Returns the last command's stdout as the step result.
839            //
840            // Note vs CLI: noetl-tools' ShellTool collects stdout +
841            // stderr and returns them in the ToolResult at the end
842            // of execution.  The CLI's inline implementation
843            // streamed output to the terminal line-by-line as the
844            // command ran.  For long-running shell steps users no
845            // longer see real-time output.  Documented in the PR
846            // body and on the executor-crate-architecture wiki
847            // page's semantic-divergence table.
848            let commands: Vec<String> = match cmds {
849                CmdsList::Single(cmd) => cmd
850                    .lines()
851                    .map(|s| s.trim())
852                    .filter(|s| !s.is_empty())
853                    .map(|s| s.to_string())
854                    .collect(),
855                CmdsList::Multiple(c) => c.clone(),
856            };
857
858            let shell_tool = ShellTool::new();
859            let ctx = to_tools_context(bridge);
860            let mut last_outcome = BridgeOutcome::empty();
861            for command in commands {
862                let config = shell_command_config(&command);
863                let result = shell_tool
864                    .execute(&config, &ctx)
865                    .await
866                    .map_err(|e| anyhow::anyhow!("shell dispatch failed: {}", e))?;
867
868                // noetl-tools' shell tool packs the result into
869                // ToolResult.data as a typed JSON object:
870                //   {"exit_code": i32, "stdout": String, "stderr": String}
871                // For the CLI's step-result contract (a single
872                // string = the command's stdout), we unwrap stdout
873                // directly here.  `from_tools_result` would
874                // otherwise stringify the whole JSON dict.
875                if result.status != ToolStatus::Success {
876                    let exit_code = result
877                        .data
878                        .as_ref()
879                        .and_then(|d| d.get("exit_code"))
880                        .and_then(|v| v.as_i64());
881                    anyhow::bail!(
882                        "Command failed with exit code: {:?}",
883                        exit_code
884                    );
885                }
886                let stdout = result
887                    .data
888                    .as_ref()
889                    .and_then(|d| d.get("stdout"))
890                    .and_then(|v| v.as_str())
891                    .map(|s| s.trim_end_matches('\n').to_string());
892                last_outcome = BridgeOutcome { result: stdout };
893            }
894            Ok(last_outcome)
895        }
896        Tool::Http {
897            method,
898            url,
899            headers,
900            params,
901            body,
902            auth,
903        } => {
904            // PR-2c-5: dispatch through noetl_tools::HttpTool.
905            //
906            // CLI semantics preserved:
907            // - Auth resolution via GCP ADC (gcp / google / adc).
908            // - Step result is the JSON envelope
909            //     `{"status": <int>, "body": <json-or-string>}`
910            //   regardless of HTTP status code (so playbook steps
911            //   can branch on `<step>.body.status`).
912            //
913            // Semantic divergences (documented on the executor-crate-
914            // architecture wiki page):
915            // - HTTP transport: curl subprocess → reqwest direct.
916            // - GCP token: `gcloud auth print-access-token` shellout
917            //   → `gcp_auth` crate (workload-identity aware on GKE).
918            // - Body bytes: CLI sent the body string verbatim via
919            //   `curl -d`.  noetl-tools serializes the body as JSON
920            //   when the string parses as JSON (adding Content-Type:
921            //   application/json automatically), otherwise sends it
922            //   verbatim.  See `http_body_value`.
923            let bearer = if let Some(auth_cfg) = auth {
924                Some(resolve_auth_to_bearer(auth_cfg).await?)
925            } else {
926                None
927            };
928            let config = http_tool_config(
929                method,
930                url,
931                headers,
932                params,
933                body.as_deref(),
934                bearer.as_deref(),
935            );
936            let http_tool = HttpTool::new();
937            let ctx = to_tools_context(bridge);
938            let result = http_tool
939                .execute(&config, &ctx)
940                .await
941                .map_err(|e| anyhow::anyhow!("http dispatch failed: {}", e))?;
942            reshape_http_result(result)
943        }
944        Tool::DuckDb { db, query, params } => {
945            // PR-2c-6: dispatch through noetl_tools::DuckdbTool.
946            //
947            // CLI semantics preserved:
948            // - The CLI's call site already resolved playbook-
949            //   relative paths (`resolve_duckdb_path`) and ran
950            //   `mkdir -p` on the parent directory before invoking
951            //   the bridge, so `db` here is an absolute path
952            //   string ready to hand to DuckDB.
953            // - SELECT / WITH queries return a JSON array of
954            //   objects (pretty-printed).
955            // - Non-SELECT queries return the literal envelope
956            //   `{"status": "ok"}` (CLI never exposed
957            //   noetl-tools' `affected_rows`).
958            // - Empty / missing query short-circuits to an empty
959            //   outcome, matching the CLI arm's
960            //   `if let Some(query_str) = query` guard.
961            //
962            // Feature gain: CLI's pre-PR-2c-6 inline impl took a
963            // `_params: &[String]` and silently ignored it.  The
964            // bridge now binds those params as JSON values at
965            // `?` placeholders.  Playbooks that had a stale
966            // `params:` list under a query without `?` placeholders
967            // continue to work (DuckDB ignores extra params); any
968            // playbook that *intended* the params would now see
969            // them applied — documented in the PR body.
970            let query = match query {
971                Some(q) if !q.trim().is_empty() => q,
972                _ => return Ok(BridgeOutcome::empty()),
973            };
974            let config = duckdb_tool_config(db, query, params);
975            let duckdb_tool = DuckdbTool::new();
976            let ctx = to_tools_context(bridge);
977            let result = duckdb_tool
978                .execute(&config, &ctx)
979                .await
980                .map_err(|e| anyhow::anyhow!("duckdb dispatch failed: {}", e))?;
981            reshape_duckdb_result(result)
982        }
983        Tool::Playbook { .. } => {
984            // PR-2c-7: encodes the § H.10 architectural finding.
985            //
986            // `Tool::Playbook` is the recursion case of the CLI's
987            // tree walker — it loads a sub-playbook YAML and
988            // dispatches it through the same `PlaybookRunner` the
989            // top-level invocation uses.  `PlaybookRunner` lives in
990            // the CLI binary, not in `noetl-executor` or
991            // `noetl-tools`, so routing this tool through the
992            // bridge would require either:
993            //   - dragging the tree walker into `noetl-executor`,
994            //     re-opening the § H.10 question that re-scoped
995            //     the crate to a utilities-and-types crate; or
996            //   - adding a callback trait to `noetl-tools` that
997            //     delegates back to the CLI binary, an
998            //     infrastructure layer nothing else in the
999            //     registry uses.
1000            //
1001            // The architecturally honest answer is that this tool
1002            // kind is NOT bridgeable.  The CLI's `Tool::Playbook`
1003            // arm stays inline by design.  Bailing loudly here
1004            // ensures any future code that tries to dispatch
1005            // `Tool::Playbook` through the bridge gets an
1006            // immediate, descriptive error instead of a silent
1007            // empty outcome.
1008            //
1009            // Sub-playbook variable preparation (the input + args
1010            // merging logic the CLI's call site performs before
1011            // recursing) DOES move into the executor as
1012            // [`prepare_sub_playbook_vars`] — that part is reusable
1013            // and testable independent of the tree walker.
1014            anyhow::bail!(
1015                "Tool::Playbook is not bridgeable: sub-playbook \
1016                 execution stays in the CLI's tree walker per \
1017                 § H.10 of the Rust migration roadmap. Use \
1018                 `PlaybookRunner::new(path).run()` directly from \
1019                 the CLI."
1020            );
1021        }
1022        Tool::Auth { .. } => {
1023            // PR-2c-8: `Tool::Auth` does not dispatch through the
1024            // registry.  Token resolution lives in
1025            // [`resolve_auth_to_bearer`] (added in PR-2c-5);
1026            // applying the resulting token to the CLI's
1027            // `ExecutionContext` lives in [`auth_context_updates`]
1028            // (added in PR-2c-8).  Both are sync helpers the CLI
1029            // calls directly without going through dispatch.  The
1030            // arm bails so any future code path that tries to
1031            // route a `Tool::Auth` through the registry gets a
1032            // clear, descriptive error instead of silently
1033            // returning an empty outcome.
1034            anyhow::bail!(
1035                "Tool::Auth is not bridge-dispatched: use \
1036                 `resolve_auth_to_bearer` for token resolution and \
1037                 `auth_context_updates` for applying the token to \
1038                 the caller's execution context. See § H.10 of the \
1039                 Rust migration roadmap."
1040            );
1041        }
1042        Tool::Sink { .. } => {
1043            // PR-2c-8: `Tool::Sink` does not dispatch through the
1044            // registry either.  noetl-tools' `TransferTool` is
1045            // database-to-database only (snowflake / postgres /
1046            // duckdb / http source → snowflake / postgres /
1047            // duckdb target); it has no file / GCS / object-store
1048            // target.  The CLI's three sink targets (File,
1049            // DuckDb, Gcs) each stay inline:
1050            //
1051            // - **File**: `fs::write` is a one-liner; the format
1052            //   conversion (json / yaml / csv) DID extract into
1053            //   [`format_sink_payload`] so it's reusable and
1054            //   testable.
1055            // - **DuckDb**: complex `INSERT INTO ... SELECT FROM
1056            //   read_json_auto(...)` with a single-object fallback;
1057            //   no `noetl-tools` equivalent.  Stays inline by
1058            //   design (§ H.10-style finding).
1059            // - **Gcs**: gsutil shellout.  A follow-up sub-PR
1060            //   (tracked separately) will migrate this to the
1061            //   `object_store` crate per § H.4 of Appendix H.
1062            //
1063            // The arm bails so misuse is loud.
1064            anyhow::bail!(
1065                "Tool::Sink is not bridge-dispatched: noetl-tools \
1066                 has no file / GCS / object-store target. Use \
1067                 `format_sink_payload` for format conversion; the \
1068                 CLI's sink targets (file / duckdb / gcs) stay \
1069                 inline per § H.10. GCS migration to `object_store` \
1070                 is tracked as a separate follow-up."
1071            );
1072        }
1073        Tool::Unsupported => {
1074            anyhow::bail!("unsupported tool kind");
1075        }
1076    }
1077}
1078
1079// ---------------------------------------------------------------------------
1080// Tests
1081// ---------------------------------------------------------------------------
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use crate::playbook::{AuthConfig as CliAuthConfig, SinkFormat, SinkTarget};
1087
1088    fn empty_vars() -> HashMap<String, String> {
1089        HashMap::new()
1090    }
1091
1092    fn bridge_ctx<'a>(vars: &'a HashMap<String, String>) -> BridgeContext<'a> {
1093        BridgeContext {
1094            execution_id: 12345,
1095            step: "test_step",
1096            variables: vars,
1097            server_url: String::new(),
1098            worker_id: None,
1099            command_id: None,
1100        }
1101    }
1102
1103    #[test]
1104    fn to_tools_context_wraps_string_variables_as_json_value() {
1105        let vars: HashMap<String, String> =
1106            [("workload.region".into(), "us-west-1".into())].into();
1107        let ctx = to_tools_context(&bridge_ctx(&vars));
1108        assert_eq!(ctx.execution_id, 12345);
1109        assert_eq!(ctx.step, "test_step");
1110        assert_eq!(
1111            ctx.variables.get("workload.region"),
1112            Some(&serde_json::Value::String("us-west-1".into()))
1113        );
1114        assert!(ctx.secrets.is_empty(), "secrets stay empty by default");
1115    }
1116
1117    #[test]
1118    fn to_tools_config_shell_single_cmd() {
1119        let tool = Tool::Shell {
1120            cmds: CmdsList::Single("ls -la".into()),
1121        };
1122        let cfg = to_tools_config(&tool);
1123        assert_eq!(cfg.kind, "shell");
1124        assert_eq!(cfg.config["command"], "ls -la");
1125        assert_eq!(cfg.config["shell"], "bash");
1126        assert_eq!(cfg.config["capture"], true);
1127        assert!(cfg.timeout.is_none());
1128    }
1129
1130    #[test]
1131    fn to_tools_config_shell_multiple_cmds_joins_with_newlines() {
1132        // The to_tools_config helper produces a SINGLE-command shape
1133        // by joining; the dispatch arm instead loops per command to
1134        // preserve the CLI's "fresh bash per command" semantics.
1135        let tool = Tool::Shell {
1136            cmds: CmdsList::Multiple(vec!["echo one".into(), "echo two".into()]),
1137        };
1138        let cfg = to_tools_config(&tool);
1139        assert_eq!(cfg.kind, "shell");
1140        assert_eq!(cfg.config["command"], "echo one\necho two");
1141    }
1142
1143    #[test]
1144    fn shell_command_config_emits_per_cmd_shape() {
1145        let cfg = shell_command_config("echo hi");
1146        assert_eq!(cfg.kind, "shell");
1147        assert_eq!(cfg.config["command"], "echo hi");
1148        assert_eq!(cfg.config["shell"], "bash");
1149        assert_eq!(cfg.config["capture"], true);
1150    }
1151
1152    #[test]
1153    fn to_tools_config_http_round_trips_essentials() {
1154        let tool = Tool::Http {
1155            method: "post".into(), // lowercase to verify uppercasing
1156            url: "https://example.com/api".into(),
1157            headers: HashMap::new(),
1158            params: HashMap::new(),
1159            body: Some(r#"{"k":"v"}"#.into()),
1160            auth: None,
1161        };
1162        let cfg = to_tools_config(&tool);
1163        assert_eq!(cfg.kind, "http");
1164        // noetl-tools' HttpConfig.method deserializes via
1165        // #[serde(rename_all = "UPPERCASE")] so the bridge always
1166        // uppercases the CLI's method string.
1167        assert_eq!(cfg.config["method"], "POST");
1168        assert_eq!(cfg.config["url"], "https://example.com/api");
1169        // JSON bodies are parsed into a JSON Value so reqwest
1170        // serialises them with Content-Type: application/json.
1171        assert_eq!(cfg.config["body"], serde_json::json!({"k": "v"}));
1172    }
1173
1174    #[test]
1175    fn to_tools_config_http_keeps_non_json_body_as_string() {
1176        let tool = Tool::Http {
1177            method: "POST".into(),
1178            url: "https://example.com".into(),
1179            headers: HashMap::new(),
1180            params: HashMap::new(),
1181            body: Some("not json at all".into()),
1182            auth: None,
1183        };
1184        let cfg = to_tools_config(&tool);
1185        assert_eq!(cfg.config["body"], "not json at all");
1186    }
1187
1188    #[test]
1189    fn http_body_value_parses_json_strings() {
1190        let v = http_body_value(r#"{"a":1}"#);
1191        assert_eq!(v, serde_json::json!({"a": 1}));
1192    }
1193
1194    #[test]
1195    fn http_body_value_falls_back_to_string() {
1196        let v = http_body_value("plain text body");
1197        assert_eq!(v, serde_json::Value::String("plain text body".into()));
1198    }
1199
1200    #[test]
1201    fn http_tool_config_injects_bearer_header() {
1202        let cfg = http_tool_config(
1203            "GET",
1204            "https://example.com",
1205            &HashMap::new(),
1206            &HashMap::new(),
1207            None,
1208            Some("test-token-123"),
1209        );
1210        assert_eq!(cfg.kind, "http");
1211        assert_eq!(
1212            cfg.config["headers"]["Authorization"],
1213            "Bearer test-token-123"
1214        );
1215    }
1216
1217    #[test]
1218    fn http_tool_config_preserves_caller_headers_with_bearer() {
1219        let mut hdrs = HashMap::new();
1220        hdrs.insert("X-Trace-Id".into(), "abc123".into());
1221        let cfg = http_tool_config(
1222            "POST",
1223            "https://example.com",
1224            &hdrs,
1225            &HashMap::new(),
1226            None,
1227            Some("token"),
1228        );
1229        assert_eq!(cfg.config["headers"]["X-Trace-Id"], "abc123");
1230        assert_eq!(cfg.config["headers"]["Authorization"], "Bearer token");
1231    }
1232
1233    #[test]
1234    fn http_tool_config_no_auth_omits_authorization_header() {
1235        let cfg = http_tool_config(
1236            "GET",
1237            "https://example.com",
1238            &HashMap::new(),
1239            &HashMap::new(),
1240            None,
1241            None,
1242        );
1243        let hdrs = cfg.config["headers"].as_object().unwrap();
1244        assert!(!hdrs.contains_key("Authorization"));
1245    }
1246
1247    #[test]
1248    fn reshape_http_result_extracts_envelope() {
1249        let mut result = ToolResult::success(serde_json::json!({
1250            "status_code": 200,
1251            "headers": {},
1252            "body": {"ok": true},
1253        }));
1254        result.exit_code = Some(0);
1255        let outcome = reshape_http_result(result).unwrap();
1256        let parsed: serde_json::Value =
1257            serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
1258        assert_eq!(parsed["status"], 200);
1259        assert_eq!(parsed["body"], serde_json::json!({"ok": true}));
1260    }
1261
1262    #[test]
1263    fn reshape_http_result_preserves_4xx_envelope_without_erroring() {
1264        // CLI contract: HTTP error statuses come back inside the
1265        // `{status, body}` envelope, NOT as anyhow::Error.  Only
1266        // network-transport failures bubble up.
1267        let mut result = ToolResult {
1268            status: ToolStatus::Error,
1269            data: Some(serde_json::json!({
1270                "status_code": 404,
1271                "headers": {},
1272                "body": {"error": "not found"},
1273            })),
1274            error: Some("HTTP 404 response".into()),
1275            stdout: None,
1276            stderr: None,
1277            exit_code: Some(1),
1278            duration_ms: Some(5),
1279        };
1280        result.exit_code = Some(1);
1281        let outcome = reshape_http_result(result).unwrap();
1282        let parsed: serde_json::Value =
1283            serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
1284        assert_eq!(parsed["status"], 404);
1285        assert_eq!(parsed["body"], serde_json::json!({"error": "not found"}));
1286    }
1287
1288    #[tokio::test]
1289    async fn resolve_auth_to_bearer_rejects_unknown_provider() {
1290        let cfg = CliAuthConfig {
1291            provider: "azure".into(),
1292            scopes: vec![],
1293        };
1294        let err = resolve_auth_to_bearer(&cfg).await.unwrap_err();
1295        assert!(err.to_string().contains("unsupported auth provider"));
1296    }
1297
1298    // ---- PR-2c-6 — Tool::DuckDb bridge integration -------------------
1299
1300    #[test]
1301    fn duckdb_tool_config_emits_noetl_tools_schema() {
1302        let cfg = duckdb_tool_config(
1303            ":memory:",
1304            "SELECT 1",
1305            &["arg1".to_string()],
1306        );
1307        assert_eq!(cfg.kind, "duckdb");
1308        assert_eq!(cfg.config["db_path"], ":memory:");
1309        assert_eq!(cfg.config["query"], "SELECT 1");
1310        assert_eq!(cfg.config["as_objects"], true);
1311        assert_eq!(
1312            cfg.config["params"],
1313            serde_json::json!([serde_json::Value::String("arg1".into())])
1314        );
1315    }
1316
1317    #[test]
1318    fn to_tools_config_duckdb_carries_path_and_query() {
1319        let tool = Tool::DuckDb {
1320            db: "warehouse.db".into(),
1321            query: Some("SELECT count(*) FROM orders".into()),
1322            params: vec![],
1323        };
1324        let cfg = to_tools_config(&tool);
1325        assert_eq!(cfg.kind, "duckdb");
1326        assert_eq!(cfg.config["db_path"], "warehouse.db");
1327        assert_eq!(cfg.config["query"], "SELECT count(*) FROM orders");
1328        assert_eq!(cfg.config["as_objects"], true);
1329    }
1330
1331    #[test]
1332    fn to_tools_config_duckdb_missing_query_becomes_empty_string() {
1333        let tool = Tool::DuckDb {
1334            db: ":memory:".into(),
1335            query: None,
1336            params: vec![],
1337        };
1338        let cfg = to_tools_config(&tool);
1339        assert_eq!(cfg.config["query"], "");
1340    }
1341
1342    #[test]
1343    fn reshape_duckdb_result_select_returns_rows_array() {
1344        let result = ToolResult::success(serde_json::json!({
1345            "columns": ["id", "name"],
1346            "rows": [
1347                {"id": 1, "name": "alice"},
1348                {"id": 2, "name": "bob"},
1349            ],
1350            "row_count": 2
1351        }));
1352        let outcome = reshape_duckdb_result(result).unwrap();
1353        let parsed: serde_json::Value =
1354            serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
1355        let arr = parsed.as_array().expect("result is an array");
1356        assert_eq!(arr.len(), 2);
1357        assert_eq!(arr[0]["id"], 1);
1358        assert_eq!(arr[0]["name"], "alice");
1359        assert_eq!(arr[1]["name"], "bob");
1360    }
1361
1362    #[test]
1363    fn reshape_duckdb_result_select_empty_returns_empty_array() {
1364        let result = ToolResult::success(serde_json::json!({
1365            "columns": ["id"],
1366            "rows": [],
1367            "row_count": 0
1368        }));
1369        let outcome = reshape_duckdb_result(result).unwrap();
1370        let parsed: serde_json::Value =
1371            serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
1372        assert_eq!(parsed.as_array().unwrap().len(), 0);
1373    }
1374
1375    #[test]
1376    fn reshape_duckdb_result_non_select_returns_status_envelope() {
1377        let result = ToolResult::success(serde_json::json!({
1378            "affected_rows": 3
1379        }));
1380        let outcome = reshape_duckdb_result(result).unwrap();
1381        // CLI returned the literal `{"status": "ok"}` string for
1382        // non-SELECT queries; `affected_rows` is intentionally
1383        // dropped (CLI never exposed it, so playbooks can't depend
1384        // on it).
1385        assert_eq!(outcome.result.as_deref(), Some(r#"{"status": "ok"}"#));
1386    }
1387
1388    #[tokio::test]
1389    async fn dispatch_duckdb_select_returns_rows_array() {
1390        let vars = empty_vars();
1391        let bridge = bridge_ctx(&vars);
1392        let tool = Tool::DuckDb {
1393            db: ":memory:".into(),
1394            query: Some("SELECT 1 AS num, 'hello' AS msg".into()),
1395            params: vec![],
1396        };
1397        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1398        let parsed: serde_json::Value =
1399            serde_json::from_str(outcome.result.as_deref().unwrap()).unwrap();
1400        let arr = parsed.as_array().expect("result is an array");
1401        assert_eq!(arr.len(), 1);
1402        assert_eq!(arr[0]["num"], 1);
1403        assert_eq!(arr[0]["msg"], "hello");
1404    }
1405
1406    #[tokio::test]
1407    async fn dispatch_duckdb_missing_query_returns_empty_outcome() {
1408        // Mirrors the CLI arm's `if let Some(query_str) = query` guard:
1409        // a Tool::DuckDb with no query falls through to None.
1410        let vars = empty_vars();
1411        let bridge = bridge_ctx(&vars);
1412        let tool = Tool::DuckDb {
1413            db: ":memory:".into(),
1414            query: None,
1415            params: vec![],
1416        };
1417        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1418        assert!(outcome.result.is_none());
1419    }
1420
1421    #[tokio::test]
1422    async fn dispatch_duckdb_empty_query_returns_empty_outcome() {
1423        let vars = empty_vars();
1424        let bridge = bridge_ctx(&vars);
1425        let tool = Tool::DuckDb {
1426            db: ":memory:".into(),
1427            query: Some("   ".into()), // whitespace only
1428            params: vec![],
1429        };
1430        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1431        assert!(outcome.result.is_none());
1432    }
1433
1434    // ---- PR-2c-7 — sub-playbook variable preparation ------------------
1435
1436    #[test]
1437    fn prepare_sub_playbook_vars_passes_parent_vars_through() {
1438        let parent: HashMap<String, String> =
1439            [("vars.timeout".into(), "30".into())].into();
1440        let sub = prepare_sub_playbook_vars(
1441            &parent,
1442            &HashMap::new(),
1443            &HashMap::new(),
1444            |t| Ok(t.to_string()),
1445        )
1446        .unwrap();
1447        assert_eq!(sub.get("vars.timeout"), Some(&"30".to_string()));
1448    }
1449
1450    #[test]
1451    fn prepare_sub_playbook_vars_v2_input_takes_precedence_over_v1_args() {
1452        let parent: HashMap<String, String> = HashMap::new();
1453        let mut input = HashMap::new();
1454        input.insert(
1455            "region".into(),
1456            serde_yaml::Value::String("us-east-1".into()),
1457        );
1458        let mut args = HashMap::new();
1459        args.insert("region".into(), "us-west-1".into());
1460
1461        let sub = prepare_sub_playbook_vars(&parent, &args, &input, |t| {
1462            Ok(t.to_string())
1463        })
1464        .unwrap();
1465        // input wins; args ignored when input is non-empty.
1466        assert_eq!(sub.get("workload.region"), Some(&"us-east-1".to_string()));
1467    }
1468
1469    #[test]
1470    fn prepare_sub_playbook_vars_v1_args_used_when_input_empty() {
1471        let parent: HashMap<String, String> = HashMap::new();
1472        let mut args = HashMap::new();
1473        args.insert("tier".into(), "prod".into());
1474        let sub = prepare_sub_playbook_vars(
1475            &parent,
1476            &args,
1477            &HashMap::new(),
1478            |t| Ok(t.to_string()),
1479        )
1480        .unwrap();
1481        assert_eq!(sub.get("workload.tier"), Some(&"prod".to_string()));
1482    }
1483
1484    #[test]
1485    fn prepare_sub_playbook_vars_renders_input_templates() {
1486        let parent: HashMap<String, String> = HashMap::new();
1487        let mut input = HashMap::new();
1488        input.insert(
1489            "url".into(),
1490            serde_yaml::Value::String("{{base}}/api".into()),
1491        );
1492        let sub = prepare_sub_playbook_vars(
1493            &parent,
1494            &HashMap::new(),
1495            &input,
1496            |t| Ok(t.replace("{{base}}", "https://example.com")),
1497        )
1498        .unwrap();
1499        assert_eq!(
1500            sub.get("workload.url"),
1501            Some(&"https://example.com/api".to_string())
1502        );
1503    }
1504
1505    #[test]
1506    fn prepare_sub_playbook_vars_coerces_yaml_numbers_and_bools() {
1507        let parent: HashMap<String, String> = HashMap::new();
1508        let mut input = HashMap::new();
1509        input.insert(
1510            "timeout".into(),
1511            serde_yaml::Value::Number(serde_yaml::Number::from(30)),
1512        );
1513        input.insert("verbose".into(), serde_yaml::Value::Bool(true));
1514        let sub = prepare_sub_playbook_vars(
1515            &parent,
1516            &HashMap::new(),
1517            &input,
1518            |t| Ok(t.to_string()),
1519        )
1520        .unwrap();
1521        assert_eq!(sub.get("workload.timeout"), Some(&"30".to_string()));
1522        assert_eq!(sub.get("workload.verbose"), Some(&"true".to_string()));
1523    }
1524
1525    #[test]
1526    fn prepare_sub_playbook_vars_passes_through_when_both_empty() {
1527        let parent: HashMap<String, String> = [(
1528            "workload.region".into(),
1529            "us-east-1".into(),
1530        )]
1531        .into();
1532        let sub = prepare_sub_playbook_vars(
1533            &parent,
1534            &HashMap::new(),
1535            &HashMap::new(),
1536            |t| Ok(t.to_string()),
1537        )
1538        .unwrap();
1539        // No input or args; parent vars come through unchanged.
1540        assert_eq!(sub.len(), 1);
1541        assert_eq!(
1542            sub.get("workload.region"),
1543            Some(&"us-east-1".to_string())
1544        );
1545    }
1546
1547    #[test]
1548    fn prepare_sub_playbook_vars_render_error_propagates() {
1549        let parent: HashMap<String, String> = HashMap::new();
1550        let mut input = HashMap::new();
1551        input.insert(
1552            "bad".into(),
1553            serde_yaml::Value::String("{{nope}}".into()),
1554        );
1555        let result = prepare_sub_playbook_vars(
1556            &parent,
1557            &HashMap::new(),
1558            &input,
1559            |_| Err(anyhow::anyhow!("render exploded")),
1560        );
1561        assert!(result.unwrap_err().to_string().contains("render exploded"));
1562    }
1563
1564    // ---- PR-2c-8 — Tool::Auth context updates -------------------------
1565
1566    #[test]
1567    fn auth_context_updates_includes_token_and_provider() {
1568        let updates = auth_context_updates("gcp", "tok-123", None);
1569        let map: HashMap<String, String> = updates.into_iter().collect();
1570        assert_eq!(map.get("auth.token"), Some(&"tok-123".to_string()));
1571        assert_eq!(map.get("auth.provider"), Some(&"gcp".to_string()));
1572        assert!(map.get("auth.project").is_none());
1573    }
1574
1575    #[test]
1576    fn auth_context_updates_includes_project_when_set() {
1577        let updates = auth_context_updates("adc", "t", Some("my-project"));
1578        let map: HashMap<String, String> = updates.into_iter().collect();
1579        assert_eq!(
1580            map.get("auth.project"),
1581            Some(&"my-project".to_string())
1582        );
1583        assert_eq!(map.get("auth.token"), Some(&"t".to_string()));
1584        assert_eq!(map.get("auth.provider"), Some(&"adc".to_string()));
1585    }
1586
1587    #[test]
1588    fn auth_context_updates_skips_empty_project() {
1589        let updates = auth_context_updates("gcp", "t", Some(""));
1590        let map: HashMap<String, String> = updates.into_iter().collect();
1591        assert!(map.get("auth.project").is_none());
1592    }
1593
1594    #[test]
1595    fn auth_context_updates_orders_project_before_token() {
1596        // The CLI's pre-PR-2c-8 inline arm set `auth.project` first,
1597        // then the token + provider after the auth call.  Preserve
1598        // that ordering so observable side-effects (logs, traces)
1599        // match.
1600        let updates = auth_context_updates("gcp", "t", Some("p"));
1601        assert_eq!(updates[0].0, "auth.project");
1602        assert_eq!(updates[1].0, "auth.token");
1603        assert_eq!(updates[2].0, "auth.provider");
1604    }
1605
1606    // ---- PR-2c-8 — Sink payload formatting + CSV ----------------------
1607
1608    #[test]
1609    fn format_sink_payload_json_passthrough() {
1610        let raw = r#"{"k": "v"}"#;
1611        let out = format_sink_payload(&SinkFormat::Json, raw).unwrap();
1612        assert_eq!(out, raw);
1613    }
1614
1615    #[test]
1616    fn format_sink_payload_yaml_converts_json_object() {
1617        let raw = r#"{"k": "v"}"#;
1618        let out = format_sink_payload(&SinkFormat::Yaml, raw).unwrap();
1619        let reparsed: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
1620        assert_eq!(reparsed["k"].as_str(), Some("v"));
1621    }
1622
1623    #[test]
1624    fn format_sink_payload_yaml_falls_back_when_not_json() {
1625        let raw = "not even close to json";
1626        let out = format_sink_payload(&SinkFormat::Yaml, raw).unwrap();
1627        assert_eq!(out, raw);
1628    }
1629
1630    #[test]
1631    fn format_sink_payload_csv_uses_json_to_csv() {
1632        let raw = r#"[{"a":1,"b":2},{"a":3,"b":4}]"#;
1633        let out = format_sink_payload(&SinkFormat::Csv, raw).unwrap();
1634        assert!(out.contains("a,b\n") || out.contains("b,a\n"));
1635        // Two data rows + header.
1636        assert_eq!(out.lines().count(), 3);
1637    }
1638
1639    #[test]
1640    fn json_to_csv_returns_input_for_non_array() {
1641        assert_eq!(json_to_csv("not json").unwrap(), "not json");
1642        assert_eq!(json_to_csv(r#"{"k":"v"}"#).unwrap(), r#"{"k":"v"}"#);
1643    }
1644
1645    #[test]
1646    fn json_to_csv_returns_input_for_empty_array() {
1647        assert_eq!(json_to_csv("[]").unwrap(), "[]");
1648    }
1649
1650    #[test]
1651    fn json_to_csv_emits_header_and_rows_for_object_array() {
1652        let raw = r#"[{"name":"alice","age":30},{"name":"bob","age":25}]"#;
1653        let csv = json_to_csv(raw).unwrap();
1654        let lines: Vec<&str> = csv.lines().collect();
1655        assert_eq!(lines.len(), 3);
1656        // Header derived from first object's keys (order
1657        // preserved by serde_json::Map).
1658        assert!(lines[0] == "name,age" || lines[0] == "age,name");
1659        // Each subsequent line should contain both values.
1660        assert!(lines[1].contains("alice") && lines[1].contains("30"));
1661        assert!(lines[2].contains("bob") && lines[2].contains("25"));
1662    }
1663
1664    #[test]
1665    fn json_to_csv_quotes_strings_with_commas() {
1666        let raw = r#"[{"label":"a, b","n":1}]"#;
1667        let csv = json_to_csv(raw).unwrap();
1668        // Quoted field with the comma preserved inside.
1669        assert!(csv.contains("\"a, b\""), "csv: {csv}");
1670    }
1671
1672    #[test]
1673    fn json_to_csv_doubles_embedded_quotes() {
1674        let raw = r#"[{"q":"she said \"hi\""}]"#;
1675        let csv = json_to_csv(raw).unwrap();
1676        // RFC-4180-style: embedded `"` doubled, whole field quoted.
1677        assert!(csv.contains("\"she said \"\"hi\"\"\""), "csv: {csv}");
1678    }
1679
1680    #[test]
1681    fn json_to_csv_missing_field_emits_empty() {
1682        let raw = r#"[{"a":1,"b":2},{"a":3}]"#; // second row missing `b`
1683        let csv = json_to_csv(raw).unwrap();
1684        let lines: Vec<&str> = csv.lines().collect();
1685        // The second data row should end with a trailing comma or
1686        // have an empty field for `b`.
1687        assert!(
1688            lines[2].ends_with(",") || lines[2].contains(",,"),
1689            "csv: {csv}"
1690        );
1691    }
1692
1693    #[test]
1694    fn to_tools_config_rhai_carries_code() {
1695        let tool = Tool::Rhai {
1696            code: "let x = 1; x + 1".into(),
1697            args: HashMap::new(),
1698        };
1699        let cfg = to_tools_config(&tool);
1700        assert_eq!(cfg.kind, "rhai");
1701        assert_eq!(cfg.config["code"], "let x = 1; x + 1");
1702    }
1703
1704    #[test]
1705    fn to_tools_config_sink_emits_typed_target() {
1706        let tool = Tool::Sink {
1707            target: SinkTarget::File {
1708                path: "/tmp/out.json".into(),
1709            },
1710            format: SinkFormat::Json,
1711        };
1712        let cfg = to_tools_config(&tool);
1713        assert_eq!(cfg.kind, "sink");
1714        assert_eq!(cfg.config["target"]["type"], "file");
1715        assert_eq!(cfg.config["target"]["path"], "/tmp/out.json");
1716        assert_eq!(cfg.config["format"], "json");
1717    }
1718
1719    #[test]
1720    fn from_tools_result_success_returns_data_string() {
1721        let result = ToolResult::success(serde_json::Value::String("hello".into()));
1722        let outcome = from_tools_result(result).unwrap();
1723        assert_eq!(outcome.result, Some("hello".into()));
1724    }
1725
1726    #[test]
1727    fn from_tools_result_success_serialises_non_string_data() {
1728        let result = ToolResult::success(serde_json::json!({"k": "v"}));
1729        let outcome = from_tools_result(result).unwrap();
1730        assert_eq!(outcome.result, Some(r#"{"k":"v"}"#.into()));
1731    }
1732
1733    #[test]
1734    fn from_tools_result_success_falls_back_to_stdout() {
1735        let mut result = ToolResult::success(serde_json::Value::Null);
1736        result.data = None;
1737        result.stdout = Some("script output".into());
1738        let outcome = from_tools_result(result).unwrap();
1739        assert_eq!(outcome.result, Some("script output".into()));
1740    }
1741
1742    #[test]
1743    fn from_tools_result_error_propagates_message() {
1744        let result = ToolResult::error("connection refused");
1745        let err = from_tools_result(result).unwrap_err();
1746        assert!(err.to_string().contains("connection refused"));
1747    }
1748
1749    // PR-2c-8 removed the
1750    // `dispatch_via_registry_returns_empty_for_unwired_kind` test:
1751    // every Tool variant now either dispatches through the registry
1752    // (Rhai/Shell/Http/DuckDb), bails with a § H.10 finding
1753    // (Playbook/Auth/Sink), or bails as unsupported.  See the
1754    // per-variant dispatch tests for the wired kinds and the bail
1755    // tests for Playbook/Auth/Sink/Unsupported.
1756
1757    #[tokio::test]
1758    async fn dispatch_auth_bails_pointing_at_helper() {
1759        // PR-2c-8: Tool::Auth has no bridge dispatch path.  The
1760        // bridge bails with a message pointing at
1761        // `resolve_auth_to_bearer` + `auth_context_updates` so
1762        // misuse is loud rather than silent.
1763        let vars = empty_vars();
1764        let bridge = bridge_ctx(&vars);
1765        let tool = Tool::Auth {
1766            provider: "adc".into(),
1767            scopes: vec![],
1768            project: None,
1769        };
1770        let err = dispatch_via_registry(&tool, &bridge).await.unwrap_err();
1771        let msg = err.to_string();
1772        assert!(
1773            msg.contains("Tool::Auth")
1774                && msg.contains("resolve_auth_to_bearer")
1775                && msg.contains("auth_context_updates"),
1776            "error should point at the helpers: {msg}"
1777        );
1778    }
1779
1780    #[tokio::test]
1781    async fn dispatch_sink_bails_pointing_at_helper() {
1782        // PR-2c-8: Tool::Sink has no bridge dispatch path either —
1783        // noetl-tools' TransferTool is database-to-database only.
1784        // The bridge bails with a message pointing at
1785        // `format_sink_payload` for format conversion.
1786        let vars = empty_vars();
1787        let bridge = bridge_ctx(&vars);
1788        let tool = Tool::Sink {
1789            target: crate::playbook::SinkTarget::File {
1790                path: "/tmp/out.json".into(),
1791            },
1792            format: SinkFormat::Json,
1793        };
1794        let err = dispatch_via_registry(&tool, &bridge).await.unwrap_err();
1795        let msg = err.to_string();
1796        assert!(
1797            msg.contains("Tool::Sink") && msg.contains("format_sink_payload"),
1798            "error should point at the helper: {msg}"
1799        );
1800    }
1801
1802    #[tokio::test]
1803    async fn dispatch_playbook_bails_with_h10_finding() {
1804        // PR-2c-7: `Tool::Playbook` is not bridgeable.  Make sure
1805        // the dispatch arm bails with a descriptive error rather
1806        // than silently returning an empty outcome.
1807        let vars = empty_vars();
1808        let bridge = bridge_ctx(&vars);
1809        let tool = Tool::Playbook {
1810            path: "sub.yaml".into(),
1811            args: HashMap::new(),
1812            input: HashMap::new(),
1813        };
1814        let err = dispatch_via_registry(&tool, &bridge).await.unwrap_err();
1815        let msg = err.to_string();
1816        assert!(
1817            msg.contains("Tool::Playbook")
1818                && msg.contains("not bridgeable")
1819                && msg.contains("§ H.10"),
1820            "error message should explain the § H.10 finding: {msg}"
1821        );
1822    }
1823
1824    // ---- PR-2c-4 — Tool::Shell bridge integration --------------------
1825
1826    #[tokio::test]
1827    async fn dispatch_shell_single_command_returns_stdout() {
1828        let vars = empty_vars();
1829        let bridge = bridge_ctx(&vars);
1830        let tool = Tool::Shell {
1831            cmds: CmdsList::Single("echo bridged".into()),
1832        };
1833        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1834        // The bridge trims the trailing newline that `echo` adds so
1835        // the step result matches the CLI's pre-PR-2c-4 contract
1836        // (per-line stdout joined without trailing whitespace).
1837        assert_eq!(outcome.result, Some("bridged".into()));
1838    }
1839
1840    #[tokio::test]
1841    async fn dispatch_shell_multiple_returns_last_command_stdout() {
1842        // CLI semantic: with CmdsList::Multiple, each command runs
1843        // in its own bash invocation; the step result is the last
1844        // command's stdout.
1845        let vars = empty_vars();
1846        let bridge = bridge_ctx(&vars);
1847        let tool = Tool::Shell {
1848            cmds: CmdsList::Multiple(vec![
1849                "echo first".into(),
1850                "echo second".into(),
1851                "echo third".into(),
1852            ]),
1853        };
1854        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1855        assert_eq!(outcome.result, Some("third".into()));
1856    }
1857
1858    #[tokio::test]
1859    async fn dispatch_shell_failure_propagates_error() {
1860        let vars = empty_vars();
1861        let bridge = bridge_ctx(&vars);
1862        let tool = Tool::Shell {
1863            cmds: CmdsList::Single("exit 7".into()),
1864        };
1865        let err = dispatch_via_registry(&tool, &bridge).await.unwrap_err();
1866        // noetl-tools' shell tool reports non-zero exit codes by
1867        // surfacing ToolResult.status == Error or by returning
1868        // result with exit_code set; either way the bridge's
1869        // from_tools_result converts that into an anyhow::Error.
1870        assert!(
1871            err.to_string().contains("shell")
1872                || err.to_string().contains("exit")
1873                || err.to_string().contains("failed"),
1874            "error message: {}",
1875            err
1876        );
1877    }
1878
1879    #[tokio::test]
1880    async fn dispatch_shell_single_with_newlines_runs_each_line_independently() {
1881        // CLI semantic: CmdsList::Single splits on newlines into
1882        // separate bash invocations.  This means `cd /tmp` on one
1883        // line doesn't change the cwd of the next line.
1884        let vars = empty_vars();
1885        let bridge = bridge_ctx(&vars);
1886        let tool = Tool::Shell {
1887            cmds: CmdsList::Single("echo first_line\necho second_line".into()),
1888        };
1889        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1890        assert_eq!(outcome.result, Some("second_line".into()));
1891    }
1892
1893    #[tokio::test]
1894    async fn dispatch_via_registry_unsupported_errors() {
1895        let vars = empty_vars();
1896        let bridge = bridge_ctx(&vars);
1897        let tool = Tool::Unsupported;
1898        let err = dispatch_via_registry(&tool, &bridge).await.unwrap_err();
1899        assert!(err.to_string().contains("unsupported"));
1900    }
1901
1902    // ---- PR-2c-3 — Tool::Rhai bridge integration ---------------------
1903
1904    #[tokio::test]
1905    async fn dispatch_rhai_evaluates_simple_arithmetic() {
1906        let vars = empty_vars();
1907        let bridge = bridge_ctx(&vars);
1908        let tool = Tool::Rhai {
1909            code: "let x = 40; let y = 2; (x + y).to_string()".into(),
1910            args: HashMap::new(),
1911        };
1912        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1913        assert_eq!(outcome.result, Some("42".into()));
1914    }
1915
1916    #[tokio::test]
1917    async fn dispatch_rhai_reads_workload_variable_via_scope() {
1918        // `to_tools_context_for_rhai` groups the CLI's flat
1919        // `workload.region` key into a nested `workload` Map.
1920        // Rhai's `workload.region` then resolves as field access.
1921        let vars: HashMap<String, String> =
1922            [("workload.region".into(), "us-west-1".into())].into();
1923        let bridge = bridge_ctx(&vars);
1924        let tool = Tool::Rhai {
1925            code: r#"workload.region.to_string()"#.into(),
1926            args: HashMap::new(),
1927        };
1928        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1929        assert_eq!(outcome.result, Some("us-west-1".into()));
1930    }
1931
1932    #[tokio::test]
1933    async fn dispatch_rhai_reads_step_result_via_field_access() {
1934        // Step results in the CLI surface as `<step>.result` keys.
1935        // The nested-shape adapter groups them under a step-named map.
1936        let vars: HashMap<String, String> = [
1937            ("check_health.result".into(), "ok".into()),
1938            ("check_health.status".into(), "200".into()),
1939        ]
1940        .into();
1941        let bridge = bridge_ctx(&vars);
1942        let tool = Tool::Rhai {
1943            code: r#"check_health.result.to_string()"#.into(),
1944            args: HashMap::new(),
1945        };
1946        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1947        assert_eq!(outcome.result, Some("ok".into()));
1948    }
1949
1950    #[test]
1951    fn to_tools_context_for_rhai_groups_workload_prefix() {
1952        let vars: HashMap<String, String> = [
1953            ("workload.region".into(), "us-west-1".into()),
1954            ("workload.tier".into(), "prod".into()),
1955            ("vars.timeout".into(), "30".into()),
1956            ("step_a.result".into(), "done".into()),
1957            ("toplevel".into(), "kept_at_root".into()),
1958        ]
1959        .into();
1960        let bridge = bridge_ctx(&vars);
1961        let ctx = to_tools_context_for_rhai(&bridge);
1962
1963        let workload = ctx
1964            .variables
1965            .get("workload")
1966            .expect("workload group should exist")
1967            .as_object()
1968            .expect("workload should be an object");
1969        assert_eq!(workload.get("region"), Some(&serde_json::json!("us-west-1")));
1970        assert_eq!(workload.get("tier"), Some(&serde_json::json!("prod")));
1971
1972        let vars_map = ctx.variables.get("vars").and_then(|v| v.as_object()).unwrap();
1973        assert_eq!(vars_map.get("timeout"), Some(&serde_json::json!("30")));
1974
1975        let step_a = ctx.variables.get("step_a").and_then(|v| v.as_object()).unwrap();
1976        assert_eq!(step_a.get("result"), Some(&serde_json::json!("done")));
1977
1978        assert_eq!(
1979            ctx.variables.get("toplevel"),
1980            Some(&serde_json::json!("kept_at_root"))
1981        );
1982    }
1983
1984    #[tokio::test]
1985    async fn dispatch_rhai_string_literal_returns_unquoted() {
1986        let vars = empty_vars();
1987        let bridge = bridge_ctx(&vars);
1988        let tool = Tool::Rhai {
1989            code: r#""hello world""#.into(),
1990            args: HashMap::new(),
1991        };
1992        let outcome = dispatch_via_registry(&tool, &bridge).await.unwrap();
1993        // noetl-tools' RhaiTool returns the result through ToolResult.data
1994        // as a JSON value; for string results that means a JSON-quoted
1995        // string.  from_tools_result strips the JSON quotes when data
1996        // is a Value::String.
1997        assert_eq!(outcome.result, Some("hello world".into()));
1998    }
1999
2000    // ---- Compiler proof: AuthConfig from playbook is still constructable
2001    // even though we don't pass it through to the bridge yet.  Locks in
2002    // the field surface so PR-2c-5 / PR-2c-8 see a deliberate gap, not
2003    // a missing type.
2004    #[test]
2005    fn cli_auth_config_constructs() {
2006        let _auth = CliAuthConfig {
2007            provider: "adc".into(),
2008            scopes: vec!["https://www.googleapis.com/auth/cloud-platform".into()],
2009        };
2010    }
2011}