use std::io::Write as _;
use std::path::PathBuf;
use async_trait::async_trait;
use salvor_server::ToolRegistry;
use salvor_tools::{HandlerError, Tool, ToolCtx, ToolHandler, ToolOutcome, TypedTool};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[must_use]
pub fn registry() -> ToolRegistry {
ToolRegistry::new()
.with_tool(std::sync::Arc::new(TypedTool::new(LookupInvoice)))
.with_tool(std::sync::Arc::new(TypedTool::new(IssueRefund)))
.with_tool(std::sync::Arc::new(TypedTool::new(SendEmail)))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LookupInvoiceInput {
pub invoice_id: String,
}
#[derive(Debug, Serialize, JsonSchema, PartialEq)]
pub struct LookupInvoiceOutput {
pub invoice_id: String,
pub amount_usd: f64,
pub watchers: Vec<String>,
}
#[derive(Tool)]
#[tool(
effect = "read",
description = "Look up a canned invoice record by id (deterministic demo data)."
)]
pub struct LookupInvoice;
const CANNED_INVOICES: &[(&str, f64, &[&str])] = &[
(
"inv_1001",
128.50,
&["alice@example.com", "bob@example.com"],
),
("inv_2002", 512.00, &["carol@example.com"]),
("inv_3003", 47.25, &["dave@example.com", "erin@example.com"]),
];
fn fallback_invoice(invoice_id: &str) -> (f64, Vec<String>) {
let sum: u32 = invoice_id.bytes().map(u32::from).sum();
let amount_usd = f64::from(sum % 10_000) / 100.0;
let roster = [
"alice@example.com",
"bob@example.com",
"carol@example.com",
"dave@example.com",
];
let watcher = roster[(sum as usize) % roster.len()].to_owned();
(amount_usd, vec![watcher])
}
#[async_trait]
impl ToolHandler for LookupInvoice {
type Input = LookupInvoiceInput;
type Output = LookupInvoiceOutput;
async fn call(
&self,
_ctx: &ToolCtx,
input: Self::Input,
) -> Result<ToolOutcome<Self::Output>, HandlerError> {
let (amount_usd, watchers) = CANNED_INVOICES
.iter()
.find(|(id, ..)| *id == input.invoice_id)
.map(|(_, amount, watchers)| {
(*amount, watchers.iter().map(|w| (*w).to_owned()).collect())
})
.unwrap_or_else(|| fallback_invoice(&input.invoice_id));
Ok(ToolOutcome::Output(LookupInvoiceOutput {
invoice_id: input.invoice_id,
amount_usd,
watchers,
}))
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct IssueRefundInput {
pub invoice_id: String,
pub amount_usd: f64,
#[serde(default)]
pub watcher: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema, PartialEq)]
pub struct IssueRefundOutput {
pub refund_id: String,
pub invoice_id: String,
pub amount_usd: f64,
pub watcher: String,
}
const DEFAULT_WATCHER: &str = "watchers@example.com";
#[derive(Tool)]
#[tool(
effect = "write",
description = "Issue a refund for an invoice, durably recording it (demo data; no real payment provider)."
)]
pub struct IssueRefund;
fn ledger_path() -> PathBuf {
if let Ok(from_env) = std::env::var("SALVOR_DEMO_TOOL_LEDGER")
&& !from_env.is_empty()
{
return PathBuf::from(from_env);
}
PathBuf::from("demo-tool-ledger.txt")
}
#[async_trait]
impl ToolHandler for IssueRefund {
type Input = IssueRefundInput;
type Output = IssueRefundOutput;
async fn call(
&self,
ctx: &ToolCtx,
input: Self::Input,
) -> Result<ToolOutcome<Self::Output>, HandlerError> {
let refund_id = format!(
"rfnd_{:08x}",
simple_hash(&format!("{}:{:.2}", input.invoice_id, input.amount_usd))
);
let watcher = input.watcher.unwrap_or_else(|| DEFAULT_WATCHER.to_owned());
let key = ctx.idempotency_key().unwrap_or("none");
let line = format!(
"issue_refund invoice_id={} amount_usd={:.2} refund_id={refund_id} watcher={watcher} key={key}",
input.invoice_id, input.amount_usd
);
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(ledger_path())
.and_then(|mut file| writeln!(file, "{line}"))
.map_err(HandlerError::new)?;
Ok(ToolOutcome::Output(IssueRefundOutput {
refund_id,
invoice_id: input.invoice_id,
amount_usd: input.amount_usd,
watcher,
}))
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SendEmailInput {
pub watcher: String,
pub invoice_id: String,
}
#[derive(Debug, Serialize, JsonSchema, PartialEq)]
pub struct SendEmailOutput {
pub watcher: String,
pub message_id: String,
}
#[derive(Tool)]
#[tool(
effect = "idempotent",
description = "Notify a watcher about an invoice by email (demo data; no real mail is sent)."
)]
pub struct SendEmail;
#[async_trait]
impl ToolHandler for SendEmail {
type Input = SendEmailInput;
type Output = SendEmailOutput;
async fn call(
&self,
ctx: &ToolCtx,
input: Self::Input,
) -> Result<ToolOutcome<Self::Output>, HandlerError> {
let seed = ctx
.idempotency_key()
.map(str::to_owned)
.unwrap_or_else(|| format!("{}:{}", input.watcher, input.invoice_id));
let message_id = format!("msg_{:08x}", simple_hash(&seed));
Ok(ToolOutcome::Output(SendEmailOutput {
watcher: input.watcher,
message_id,
}))
}
}
fn simple_hash(s: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
#[cfg(test)]
mod tests {
use salvor_tools::{DynTool, Effect, ToolMeta};
use serde_json::json;
use super::*;
#[tokio::test]
async fn lookup_invoice_is_read_and_resolves_the_canned_table() {
assert_eq!(LookupInvoice::NAME, "lookup_invoice");
assert_eq!(LookupInvoice::EFFECT, Effect::Read);
let tool = TypedTool::new(LookupInvoice);
let outcome = tool
.call_json(&ToolCtx::new(None), json!({ "invoice_id": "inv_1001" }))
.await
.expect("dispatch succeeds");
match outcome {
ToolOutcome::Output(value) => assert_eq!(
value,
json!({
"invoice_id": "inv_1001",
"amount_usd": 128.5,
"watchers": ["alice@example.com", "bob@example.com"],
})
),
ToolOutcome::Suspend(_) => panic!("expected an output"),
}
}
#[tokio::test]
async fn lookup_invoice_falls_back_deterministically_for_an_unknown_id() {
let tool = TypedTool::new(LookupInvoice);
let a = tool
.call_json(&ToolCtx::new(None), json!({ "invoice_id": "inv_unknown" }))
.await
.expect("dispatch succeeds");
let b = tool
.call_json(&ToolCtx::new(None), json!({ "invoice_id": "inv_unknown" }))
.await
.expect("dispatch succeeds");
assert_eq!(a, b, "the same unrecognized id must resolve identically");
}
#[tokio::test]
async fn issue_refund_is_write_and_appends_one_durable_line() {
assert_eq!(IssueRefund::NAME, "issue_refund");
assert_eq!(IssueRefund::EFFECT, Effect::Write);
let dir = tempfile::tempdir().expect("tempdir");
let ledger = dir.path().join("ledger.txt");
unsafe {
std::env::set_var("SALVOR_DEMO_TOOL_LEDGER", &ledger);
}
let tool = TypedTool::new(IssueRefund);
let outcome = tool
.call_json(
&ToolCtx::new(Some("key-1".to_owned())),
json!({ "invoice_id": "inv_1001", "amount_usd": 128.5 }),
)
.await
.expect("dispatch succeeds");
match outcome {
ToolOutcome::Output(value) => {
assert_eq!(value["invoice_id"], "inv_1001");
assert_eq!(value["amount_usd"], 128.5);
assert!(value["refund_id"].as_str().unwrap().starts_with("rfnd_"));
}
ToolOutcome::Suspend(_) => panic!("expected an output"),
}
let written = std::fs::read_to_string(&ledger).expect("ledger written");
assert_eq!(
written.lines().count(),
1,
"exactly one durable line: {written}"
);
assert!(written.contains("invoice_id=inv_1001"));
assert!(written.contains("key=key-1"));
unsafe {
std::env::remove_var("SALVOR_DEMO_TOOL_LEDGER");
}
}
#[tokio::test]
async fn send_email_is_idempotent_and_a_retried_key_reproduces_the_same_message_id() {
assert_eq!(SendEmail::NAME, "send_email");
assert_eq!(SendEmail::EFFECT, Effect::Idempotent);
let tool = TypedTool::new(SendEmail);
let input = json!({ "watcher": "alice@example.com", "invoice_id": "inv_1001" });
let first = tool
.call_json(&ToolCtx::new(Some("retry-key".to_owned())), input.clone())
.await
.expect("dispatch succeeds");
let second = tool
.call_json(&ToolCtx::new(Some("retry-key".to_owned())), input)
.await
.expect("dispatch succeeds");
assert_eq!(
first, second,
"a retried call under the same idempotency key must reproduce the identical output"
);
}
#[test]
fn registry_holds_exactly_the_three_demo_tools() {
let registry = registry();
assert_eq!(registry.len(), 3);
assert!(registry.resolve("lookup_invoice").is_some());
assert!(registry.resolve("issue_refund").is_some());
assert!(registry.resolve("send_email").is_some());
assert!(registry.resolve("not_a_demo_tool").is_none());
}
}