use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use crate::context::ExecutionContext;
use crate::error::ToolError;
use crate::registry::{Tool, ToolConfig};
use crate::result::ToolResult;
use crate::template::TemplateEngine;
use super::ResultFetchTool;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactConfig {
#[serde(default = "default_action")]
action: String,
#[serde(alias = "args")]
input: ArtifactInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
prefer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
flight_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bearer_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tls_ca_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_cert_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_key_path: Option<String>,
}
fn default_action() -> String {
"get".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactInput {
result_ref: String,
}
pub struct ArtifactTool {
delegate: ResultFetchTool,
template_engine: TemplateEngine,
}
impl Default for ArtifactTool {
fn default() -> Self {
Self::new()
}
}
impl ArtifactTool {
pub fn new() -> Self {
Self {
delegate: ResultFetchTool::new(),
template_engine: TemplateEngine::new(),
}
}
fn translate_config(&self, raw: &JsonValue) -> Result<JsonValue, ToolError> {
let cfg: ArtifactConfig = serde_json::from_value(raw.clone()).map_err(|e| {
ToolError::Configuration(format!("Invalid artifact config: {e}"))
})?;
if cfg.action == "put" {
return Err(ToolError::Configuration(
"artifact action=put is not supported in the Rust path; \
a step's result lands in the result store via the worker's \
call.done emit (R-2.1), not via a tool kind invoked by the \
playbook author. See agents/rules/execution-model.md."
.to_string(),
));
}
if cfg.action != "get" {
return Err(ToolError::Configuration(format!(
"artifact action='{}' is not supported (only 'get' is implemented)",
cfg.action
)));
}
let mut translated = json!({ "ref": cfg.input.result_ref });
let obj = translated
.as_object_mut()
.expect("translated config is an object by construction");
if let Some(v) = cfg.prefer {
obj.insert("prefer".to_string(), JsonValue::String(v));
}
if let Some(v) = cfg.flight_endpoint {
obj.insert("flight_endpoint".to_string(), JsonValue::String(v));
}
if let Some(v) = cfg.bearer_token {
obj.insert("bearer_token".to_string(), JsonValue::String(v));
}
if let Some(v) = cfg.tls_ca_path {
obj.insert("tls_ca_path".to_string(), JsonValue::String(v));
}
if let Some(v) = cfg.client_cert_path {
obj.insert("client_cert_path".to_string(), JsonValue::String(v));
}
if let Some(v) = cfg.client_key_path {
obj.insert("client_key_path".to_string(), JsonValue::String(v));
}
Ok(translated)
}
}
#[async_trait]
impl Tool for ArtifactTool {
fn name(&self) -> &'static str {
"artifact"
}
async fn execute(
&self,
config: &ToolConfig,
ctx: &ExecutionContext,
) -> Result<ToolResult, ToolError> {
let template_ctx = ctx.to_template_context();
let rendered = self
.template_engine
.render_value(&config.config, &template_ctx)?;
let translated = self.translate_config(&rendered)?;
let delegate_config = ToolConfig {
kind: "result_fetch".to_string(),
config: translated,
timeout: config.timeout,
retry: config.retry.clone(),
auth: config.auth.clone(),
};
self.delegate.execute(&delegate_config, ctx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ToolConfig;
use serde_json::json;
fn tool_config(value: JsonValue) -> ToolConfig {
ToolConfig {
kind: "artifact".to_string(),
config: value,
timeout: None,
retry: None,
auth: None,
}
}
#[test]
fn translate_get_with_ref_only() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "get",
"input": { "result_ref": "noetl://execution/42/result/big_select/123" }
});
let out = tool.translate_config(&raw).expect("translate get");
assert_eq!(out.get("ref").and_then(|v| v.as_str()),
Some("noetl://execution/42/result/big_select/123"));
assert!(out.get("prefer").is_none());
assert!(out.get("flight_endpoint").is_none());
assert!(out.get("bearer_token").is_none());
}
#[test]
fn translate_get_accepts_args_alias_for_input() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "get",
"args": { "result_ref": "noetl://execution/777/result/save_step/9" }
});
let out = tool.translate_config(&raw).expect("translate get with args: alias");
assert_eq!(
out.get("ref").and_then(|v| v.as_str()),
Some("noetl://execution/777/result/save_step/9"),
"expected the artifact tool to accept `args:` as an alias for `input:`"
);
}
#[test]
fn translate_get_with_passthroughs() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "get",
"input": { "result_ref": "noetl://execution/1/result/s/1" },
"prefer": "http",
"flight_endpoint": "grpc://noetl.svc.cluster.local:8083",
"bearer_token": "flight-token-alias",
"tls_ca_path": "/etc/noetl/ca.pem",
"client_cert_path": "/etc/noetl/worker.crt",
"client_key_path": "/etc/noetl/worker.key",
});
let out = tool.translate_config(&raw).expect("translate get");
assert_eq!(out.get("ref").and_then(|v| v.as_str()),
Some("noetl://execution/1/result/s/1"));
assert_eq!(out.get("prefer").and_then(|v| v.as_str()), Some("http"));
assert_eq!(out.get("flight_endpoint").and_then(|v| v.as_str()),
Some("grpc://noetl.svc.cluster.local:8083"));
assert_eq!(out.get("bearer_token").and_then(|v| v.as_str()),
Some("flight-token-alias"));
assert_eq!(out.get("tls_ca_path").and_then(|v| v.as_str()),
Some("/etc/noetl/ca.pem"));
assert_eq!(out.get("client_cert_path").and_then(|v| v.as_str()),
Some("/etc/noetl/worker.crt"));
assert_eq!(out.get("client_key_path").and_then(|v| v.as_str()),
Some("/etc/noetl/worker.key"));
}
#[test]
fn translate_defaults_action_to_get() {
let tool = ArtifactTool::new();
let raw = json!({
"input": { "result_ref": "noetl://execution/1/result/s/1" }
});
let out = tool.translate_config(&raw).expect("translate default");
assert_eq!(out.get("ref").and_then(|v| v.as_str()),
Some("noetl://execution/1/result/s/1"));
}
#[test]
fn translate_put_returns_configuration_error_pointing_at_emit_path() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "put",
"input": { "result_ref": "noetl://execution/1/result/s/1" }
});
let err = tool.translate_config(&raw).expect_err("put must be rejected");
match err {
ToolError::Configuration(msg) => {
assert!(msg.contains("put is not supported"));
assert!(msg.contains("call.done emit"));
}
other => panic!("expected ToolError::Configuration, got {other:?}"),
}
}
#[test]
fn translate_unknown_action_is_rejected() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "rotate",
"input": { "result_ref": "noetl://execution/1/result/s/1" }
});
let err = tool.translate_config(&raw).expect_err("unknown action");
match err {
ToolError::Configuration(msg) => {
assert!(msg.contains("rotate"));
assert!(msg.contains("only 'get' is implemented"));
}
other => panic!("expected ToolError::Configuration, got {other:?}"),
}
}
#[test]
fn translate_missing_input_returns_configuration_error() {
let tool = ArtifactTool::new();
let raw = json!({ "action": "get" });
let err = tool.translate_config(&raw).expect_err("missing input");
match err {
ToolError::Configuration(msg) => {
assert!(msg.contains("input"));
}
other => panic!("expected ToolError::Configuration, got {other:?}"),
}
}
#[test]
fn tool_name_is_artifact() {
let tool = ArtifactTool::new();
assert_eq!(tool.name(), "artifact");
}
#[test]
fn tool_config_round_trip_translation() {
let tool = ArtifactTool::new();
let raw = json!({
"action": "get",
"input": { "result_ref": "noetl://execution/9/result/lazy/1" }
});
let tc = tool_config(raw.clone());
let out = tool.translate_config(&tc.config).expect("ok");
assert_eq!(out.get("ref").and_then(|v| v.as_str()),
Some("noetl://execution/9/result/lazy/1"));
}
}