use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use harn_parser::{Attribute, AttributeArg, Node, TypeExpr};
use crate::limits::{limits_and_budget_from_attributes, BudgetSpec, RouteLimits};
use crate::DispatchError;
mod diagnostics;
mod mcp_metadata;
mod schema_projection;
mod tool_catalog;
pub use diagnostics::{
emit_export_diagnostics, ExportDiagnostic, ANNOTATIONS_BAD_ARGS, JOB_BAD_NAME,
JOB_MODIFIER_WITHOUT_JOB, POLICY_BAD_ARGS, QUEUE_BAD_NAME, RAW_BAD_ARGS,
RAW_CONFLICTS_WITH_STREAM, RAW_WITHOUT_ROUTE, RETRY_BAD_ARGS, ROUTE_ARG_NOT_STRING,
ROUTE_BAD_ARITY, SCHEDULE_BAD_ARGS, SCOPES_ARG_NOT_STRING, STREAM_BAD_ARGS,
STREAM_WITHOUT_ROUTE, WS_BAD_ARGS, WS_CONFLICTS_WITH_STREAM_OR_RAW, WS_WITHOUT_ROUTE,
};
pub use mcp_metadata::ToolAnnotations;
pub use schema_projection::type_expr_accepts_json_object;
#[derive(Clone, Debug, PartialEq)]
pub struct ExportedParam {
pub name: String,
pub type_expr: Option<TypeExpr>,
pub input_schema: serde_json::Value,
pub has_default: bool,
pub rest: bool,
}
impl ExportedParam {
pub fn accepts_json_object(&self) -> bool {
self.type_expr
.as_ref()
.is_some_and(type_expr_accepts_json_object)
|| self
.input_schema
.get("type")
.and_then(serde_json::Value::as_str)
== Some("object")
|| self.input_schema.get("properties").is_some()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExportedCallableKind {
Function,
Pipeline,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RoutePolicy {
pub allowed_kinds: BTreeSet<String>,
pub match_labels: BTreeSet<String>,
pub method_guards: BTreeSet<String>,
}
impl RoutePolicy {
pub fn is_empty(&self) -> bool {
self.allowed_kinds.is_empty()
&& self.match_labels.is_empty()
&& self.method_guards.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct ExportedFunction {
pub name: String,
pub kind: ExportedCallableKind,
pub title: Option<String>,
pub description: Option<String>,
pub annotations: Option<ToolAnnotations>,
pub params: Vec<ExportedParam>,
pub return_type: Option<TypeExpr>,
pub throws_type: Option<TypeExpr>,
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
pub error_schema: Option<serde_json::Value>,
pub required_scopes: BTreeSet<String>,
pub method_scopes: BTreeMap<String, BTreeSet<String>>,
pub policy: Option<RoutePolicy>,
pub limits: Option<RouteLimits>,
pub budget: Option<BudgetSpec>,
pub route: Option<RouteSpec>,
pub job: Option<JobSpec>,
pub stream: bool,
pub raw: bool,
pub ws: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JobSpec {
pub name: String,
pub schedule: Option<ScheduleSpec>,
pub queue: Option<String>,
pub retry: Option<RetrySpec>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScheduleSpec {
pub cron: String,
pub timezone: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetrySpec {
pub max_attempts: u32,
pub backoff: RetryBackoff,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RetryBackoff {
#[default]
Svix,
Linear,
Exponential,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteSpec {
pub method: String,
pub path: String,
}
#[derive(Clone, Debug)]
pub struct ExportCatalog {
pub script_path: PathBuf,
pub functions: BTreeMap<String, ExportedFunction>,
pub instructions: Option<String>,
pub diagnostics: Vec<ExportDiagnostic>,
}
impl ExportCatalog {
pub fn from_path(path: &Path) -> Result<Self, DispatchError> {
let source = fs::read_to_string(path).map_err(|error| {
DispatchError::Io(format!("failed to read {}: {error}", path.display()))
})?;
let program = harn_parser::parse_source(&source).map_err(|error| {
DispatchError::Validation(format!("failed to parse {}: {error}", path.display()))
})?;
let schema_resolver = schema_projection::resolver_for_module(path, &program);
let source_lines: Vec<&str> = source.lines().collect();
let mut functions = BTreeMap::new();
let mut diagnostics = Vec::new();
for node in &program {
let (attrs, inner) = harn_parser::peel_attributes(node);
let Node::FnDecl {
name,
params,
return_type,
throws,
is_pub,
..
} = &inner.node
else {
continue;
};
if !*is_pub {
continue;
}
let scopes = scopes_from_attributes(attrs, name, &mut diagnostics);
let policy = policy_from_attributes(attrs, name, &mut diagnostics);
let (limits, budget) =
limits_and_budget_from_attributes(attrs).map_err(DispatchError::Validation)?;
let route = route_from_attributes(attrs, name, &mut diagnostics);
let stream = stream_from_attributes(attrs, name, route.as_ref(), &mut diagnostics);
let raw = raw_from_attributes(attrs, name, route.as_ref(), stream, &mut diagnostics);
let ws = ws_from_attributes(attrs, name, route.as_ref(), raw, &mut diagnostics);
let public_params = schema_projection::public_params(params);
let (title, description) = mcp_metadata::title_and_description(
mcp_metadata::doc_comment_above(&source_lines, inner.span.line),
);
functions.insert(
name.clone(),
ExportedFunction {
name: name.clone(),
kind: ExportedCallableKind::Function,
title,
description,
annotations: mcp_metadata::annotations_from_attributes(
attrs,
name,
&mut diagnostics,
),
params: schema_projection::exported_params(public_params, &schema_resolver),
return_type: return_type.clone(),
throws_type: throws.clone(),
input_schema: schema_resolver.json_schema_for_typed_params(public_params),
output_schema: return_type
.as_ref()
.and_then(|type_expr| schema_resolver.json_schema_for_type_expr(type_expr)),
error_schema: throws
.as_ref()
.and_then(|type_expr| schema_resolver.json_schema_for_type_expr(type_expr)),
required_scopes: scopes.baseline,
method_scopes: scopes.per_method,
policy,
limits,
budget,
route,
stream,
raw,
ws,
job: job_from_attributes(attrs, name, &mut diagnostics),
},
);
}
let has_public_exports = !functions.is_empty();
for node in &program {
let (attrs, inner) = harn_parser::peel_attributes(node);
let Node::Pipeline {
name,
params,
return_type,
throws,
is_pub,
..
} = &inner.node
else {
continue;
};
if has_public_exports && !*is_pub {
continue;
}
let scopes = scopes_from_attributes(attrs, name, &mut diagnostics);
let policy = policy_from_attributes(attrs, name, &mut diagnostics);
let (limits, budget) =
limits_and_budget_from_attributes(attrs).map_err(DispatchError::Validation)?;
let stream = stream_from_attributes(attrs, name, None, &mut diagnostics);
let raw = raw_from_attributes(attrs, name, None, stream, &mut diagnostics);
let ws = ws_from_attributes(attrs, name, None, raw, &mut diagnostics);
let public_params = schema_projection::public_params(params);
let (title, description) = mcp_metadata::title_and_description(
mcp_metadata::doc_comment_above(&source_lines, inner.span.line),
);
functions
.entry(name.clone())
.or_insert_with(|| ExportedFunction {
name: name.clone(),
kind: ExportedCallableKind::Pipeline,
title,
description,
annotations: mcp_metadata::annotations_from_attributes(
attrs,
name,
&mut diagnostics,
),
params: schema_projection::exported_params(public_params, &schema_resolver),
return_type: return_type.clone(),
throws_type: throws.clone(),
input_schema: schema_resolver.json_schema_for_typed_params(public_params),
output_schema: return_type
.as_ref()
.and_then(|type_expr| schema_resolver.json_schema_for_type_expr(type_expr)),
error_schema: throws
.as_ref()
.and_then(|type_expr| schema_resolver.json_schema_for_type_expr(type_expr)),
required_scopes: scopes.baseline,
method_scopes: scopes.per_method,
policy,
limits,
budget,
route: None,
stream,
raw,
ws,
job: job_from_attributes(attrs, name, &mut diagnostics),
});
}
Ok(Self {
script_path: path.to_path_buf(),
functions,
instructions: mcp_metadata::module_doc_comment(&source_lines),
diagnostics,
})
}
pub fn function(&self, name: &str) -> Option<&ExportedFunction> {
self.functions.get(name)
}
pub fn diagnostics(&self) -> &[ExportDiagnostic] {
&self.diagnostics
}
}
#[derive(Default)]
struct ParsedScopes {
baseline: BTreeSet<String>,
per_method: BTreeMap<String, BTreeSet<String>>,
}
const SCOPE_METHOD_PREFIXES: [&str; 7] =
["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"];
fn scopes_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> ParsedScopes {
let mut parsed = ParsedScopes::default();
for attr in attrs {
if attr.name != "scopes" {
continue;
}
for arg in &attr.args {
match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
match parse_scope_literal(value) {
Some((method, scope)) => {
parsed.per_method.entry(method).or_default().insert(scope);
}
None => {
parsed.baseline.insert(value.clone());
}
}
}
_ => diagnostics.push(ExportDiagnostic {
code: SCOPES_ARG_NOT_STRING,
line: arg.span.line,
message: format!(
"`@scopes` on `{fn_name}` requires string-literal arguments; \
dropping a non-string scope leaves the route less restricted"
),
}),
}
}
}
parsed
}
fn policy_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RoutePolicy> {
let mut policy = RoutePolicy::default();
for attr in attrs {
if attr.name != "policy" {
continue;
}
for arg in &attr.args {
let target = match arg.name.as_deref() {
Some("kinds") => Some(&mut policy.allowed_kinds),
Some("matches") => Some(&mut policy.match_labels),
Some("methods") => Some(&mut policy.method_guards),
_ => None,
};
match (target, &arg.value.node) {
(Some(target), Node::StringLiteral(value) | Node::RawStringLiteral(value)) => {
target.extend(value.split_whitespace().map(str::to_string));
}
_ => diagnostics.push(ExportDiagnostic {
code: POLICY_BAD_ARGS,
line: arg.span.line,
message: format!(
"`@policy` on `{fn_name}` accepts only string-valued `kinds`, `matches`, \
and `methods` arguments; dropping an unrecognized argument leaves the \
route's policy catalog incomplete"
),
}),
}
}
}
(!policy.is_empty()).then_some(policy)
}
fn parse_scope_literal(literal: &str) -> Option<(String, String)> {
let (first, rest) = literal.split_once(char::is_whitespace)?;
let method = first.to_ascii_uppercase();
if !SCOPE_METHOD_PREFIXES.contains(&method.as_str()) {
return None;
}
let scope = rest.trim();
if scope.is_empty() {
return None;
}
Some((method, scope.to_string()))
}
fn route_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RouteSpec> {
if attrs.iter().any(|attr| attr.name == "route") {
return explicit_route_attribute(attrs, fn_name, diagnostics);
}
handler_convention_route(fn_name)
}
fn explicit_route_attribute(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RouteSpec> {
let attr = attrs.iter().find(|attr| attr.name == "route")?;
let literals: Vec<&str> = attr
.args
.iter()
.filter_map(|arg| match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => Some(value.as_str()),
_ => None,
})
.collect();
if literals.len() != attr.args.len() {
diagnostics.push(ExportDiagnostic {
code: ROUTE_ARG_NOT_STRING,
line: attr.span.line,
message: format!(
"`@route` on `{fn_name}` requires string-literal arguments \
(`@route(\"/path\")` or `@route(\"METHOD\", \"/path\")`); handler not mounted"
),
});
return None;
}
match literals.as_slice() {
[path] => Some(RouteSpec {
method: "GET".to_string(),
path: normalize_route_path(path),
}),
[method, path] => Some(RouteSpec {
method: normalize_route_method(method),
path: normalize_route_path(path),
}),
_ => {
diagnostics.push(ExportDiagnostic {
code: ROUTE_BAD_ARITY,
line: attr.span.line,
message: format!(
"`@route` on `{fn_name}` takes a path or a method and a path \
(`@route(\"/path\")` or `@route(\"METHOD\", \"/path\")`), \
found {} arguments; handler not mounted",
literals.len()
),
});
None
}
}
}
fn handler_convention_route(fn_name: &str) -> Option<RouteSpec> {
let path = match fn_name {
"handler" => "/".to_string(),
other => {
let suffix = other.strip_prefix("handler_")?;
if suffix.is_empty() {
return None;
}
format!("/{suffix}")
}
};
Some(RouteSpec {
method: "*".to_string(),
path,
})
}
fn normalize_route_method(method: &str) -> String {
let upper = method.trim().to_ascii_uppercase();
if upper == "ANY" || upper.is_empty() {
"*".to_string()
} else {
upper
}
}
fn normalize_route_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn stream_from_attributes(
attrs: &[Attribute],
fn_name: &str,
route: Option<&RouteSpec>,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> bool {
bare_route_marker_from_attributes(
attrs,
"stream",
fn_name,
route,
diagnostics,
STREAM_BAD_ARGS,
STREAM_WITHOUT_ROUTE,
)
}
fn raw_from_attributes(
attrs: &[Attribute],
fn_name: &str,
route: Option<&RouteSpec>,
stream: bool,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> bool {
let raw = bare_route_marker_from_attributes(
attrs,
"raw",
fn_name,
route,
diagnostics,
RAW_BAD_ARGS,
RAW_WITHOUT_ROUTE,
);
if raw && stream {
let line = attrs
.iter()
.find(|attr| attr.name == "raw")
.map(|attr| attr.span.line)
.unwrap_or(0);
diagnostics.push(ExportDiagnostic {
code: RAW_CONFLICTS_WITH_STREAM,
line,
message: format!(
"`@raw` on `{fn_name}` conflicts with `@stream` (one never reads the request \
body, the other buffers it); dropping `@raw` — the route behaves as `@stream`"
),
});
return false;
}
raw
}
fn ws_from_attributes(
attrs: &[Attribute],
fn_name: &str,
route: Option<&RouteSpec>,
raw: bool,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> bool {
let ws = bare_route_marker_from_attributes(
attrs,
"ws",
fn_name,
route,
diagnostics,
WS_BAD_ARGS,
WS_WITHOUT_ROUTE,
);
if ws && raw {
let line = attrs
.iter()
.find(|attr| attr.name == "ws")
.map(|attr| attr.span.line)
.unwrap_or(0);
diagnostics.push(ExportDiagnostic {
code: WS_CONFLICTS_WITH_STREAM_OR_RAW,
line,
message: format!(
"`@ws` on `{fn_name}` conflicts with `@raw` (a WebSocket handshake carries no \
request body, but `@raw` buffers one); dropping `@ws` — the route behaves as \
`@raw`. (Pair `@ws` with `@stream` instead for a route that is both a WebSocket \
upgrade and an SSE/stream fallback.)"
),
});
return false;
}
ws
}
fn bare_route_marker_from_attributes(
attrs: &[Attribute],
marker: &str,
fn_name: &str,
route: Option<&RouteSpec>,
diagnostics: &mut Vec<ExportDiagnostic>,
bad_args_code: &'static str,
without_route_code: &'static str,
) -> bool {
let Some(attr) = attrs.iter().find(|attr| attr.name == marker) else {
return false;
};
if route.is_none() {
diagnostics.push(ExportDiagnostic {
code: without_route_code,
line: attr.span.line,
message: format!(
"`@{marker}` on `{fn_name}` has no effect without an HTTP route \
(`@route(...)` or the `handler_*` convention); ignoring it"
),
});
return false;
}
if !attr.args.is_empty() {
diagnostics.push(ExportDiagnostic {
code: bad_args_code,
line: attr.span.line,
message: format!(
"`@{marker}` on `{fn_name}` takes no arguments, found {}; marker dropped — \
the route dispatches as a plain handler",
attr.args.len()
),
});
return false;
}
true
}
fn job_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<JobSpec> {
let Some(job_attr) = attrs.iter().find(|attr| attr.name == "job") else {
for modifier in ["schedule", "queue", "retry"] {
if let Some(attr) = attrs.iter().find(|attr| attr.name == modifier) {
diagnostics.push(ExportDiagnostic {
code: JOB_MODIFIER_WITHOUT_JOB,
line: attr.span.line,
message: format!(
"`@{modifier}` on `{fn_name}` has no effect without a `@job(\"name\")` \
attribute; ignoring it"
),
});
}
}
return None;
};
let positionals: Vec<&AttributeArg> = job_attr
.args
.iter()
.filter(|arg| arg.name.is_none())
.collect();
let name = match positionals.as_slice() {
[] => fn_name.to_string(),
[arg] => match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
fn_name.to_string()
} else {
trimmed.to_string()
}
}
_ => {
diagnostics.push(ExportDiagnostic {
code: JOB_BAD_NAME,
line: job_attr.span.line,
message: format!(
"`@job` on `{fn_name}` takes an optional string-literal name \
(`@job` or `@job(\"name\")`); function not registered as a job"
),
});
return None;
}
},
_ => {
diagnostics.push(ExportDiagnostic {
code: JOB_BAD_NAME,
line: job_attr.span.line,
message: format!(
"`@job` on `{fn_name}` takes at most one string-literal name, found {}; \
function not registered as a job",
positionals.len()
),
});
return None;
}
};
Some(JobSpec {
name,
schedule: schedule_from_attributes(attrs, fn_name, diagnostics),
queue: queue_from_attributes(attrs, fn_name, diagnostics),
retry: retry_from_attributes(attrs, job_attr, fn_name, diagnostics),
})
}
fn schedule_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<ScheduleSpec> {
let attr = attrs.iter().find(|attr| attr.name == "schedule")?;
let literals: Vec<&str> = attr
.args
.iter()
.filter_map(|arg| match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => Some(value.as_str()),
_ => None,
})
.collect();
if literals.len() != attr.args.len() {
diagnostics.push(ExportDiagnostic {
code: SCHEDULE_BAD_ARGS,
line: attr.span.line,
message: format!(
"`@schedule` on `{fn_name}` requires string-literal arguments \
(`@schedule(\"cron\")` or `@schedule(\"cron\", \"timezone\")`); schedule dropped"
),
});
return None;
}
match literals.as_slice() {
[cron] => Some(ScheduleSpec {
cron: cron.trim().to_string(),
timezone: None,
}),
[cron, timezone] => Some(ScheduleSpec {
cron: cron.trim().to_string(),
timezone: Some(timezone.trim().to_string()),
}),
_ => {
diagnostics.push(ExportDiagnostic {
code: SCHEDULE_BAD_ARGS,
line: attr.span.line,
message: format!(
"`@schedule` on `{fn_name}` takes a cron expression and an optional timezone, \
found {} arguments; schedule dropped",
literals.len()
),
});
None
}
}
}
fn queue_from_attributes(
attrs: &[Attribute],
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<String> {
let attr = attrs.iter().find(|attr| attr.name == "queue")?;
match attr.args.as_slice() {
[arg] => match &arg.value.node {
Node::StringLiteral(value) | Node::RawStringLiteral(value)
if !value.trim().is_empty() =>
{
Some(value.trim().to_string())
}
_ => {
diagnostics.push(ExportDiagnostic {
code: QUEUE_BAD_NAME,
line: attr.span.line,
message: format!(
"`@queue` on `{fn_name}` requires a non-empty string-literal queue name \
(`@queue(\"queue-name\")`); queue dropped"
),
});
None
}
},
_ => {
diagnostics.push(ExportDiagnostic {
code: QUEUE_BAD_NAME,
line: attr.span.line,
message: format!(
"`@queue` on `{fn_name}` takes exactly one string-literal queue name, found {}; \
queue dropped",
attr.args.len()
),
});
None
}
}
}
fn retry_from_attributes(
attrs: &[Attribute],
job_attr: &Attribute,
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RetrySpec> {
if let Some(attr) = attrs.iter().find(|attr| attr.name == "retry") {
return retry_from_attr(attr, fn_name, diagnostics);
}
retry_from_job_attr(job_attr, fn_name, diagnostics)
}
fn retry_from_job_attr(
job_attr: &Attribute,
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RetrySpec> {
let retry_arg = job_attr
.args
.iter()
.find(|arg| arg.name.as_deref() == Some("retry"))?;
let Node::DictLiteral(entries) = &retry_arg.value.node else {
diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: retry_arg.span.line,
message: format!(
"`@job(retry:)` on `{fn_name}` requires a dict \
(`retry: {{ max: 3, backoff: \"exponential\" }}`); retry dropped"
),
});
return None;
};
Some(retry_from_entries(
entries.iter().filter_map(|entry| {
let key = match &entry.key.node {
Node::Identifier(name) => name.as_str(),
Node::StringLiteral(name) | Node::RawStringLiteral(name) => name.as_str(),
_ => return None,
};
Some((key, &entry.value.node, retry_arg.span.line, "@job(retry:)"))
}),
fn_name,
diagnostics,
))
}
fn retry_from_attr(
attr: &Attribute,
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> Option<RetrySpec> {
let mut fields = Vec::new();
for arg in &attr.args {
let Some(name) = arg.name.as_deref() else {
diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line: arg.span.line,
message: format!(
"`@retry` on `{fn_name}` accepts named arguments \
(`@retry(max: 3, backoff: \"exponential\")`); ignoring a positional argument"
),
});
continue;
};
fields.push((name, &arg.value.node, arg.span.line, "@retry"));
}
Some(retry_from_entries(fields, fn_name, diagnostics))
}
fn retry_from_entries<'a>(
entries: impl IntoIterator<Item = (&'a str, &'a Node, usize, &'static str)>,
fn_name: &str,
diagnostics: &mut Vec<ExportDiagnostic>,
) -> RetrySpec {
let mut max_attempts: u32 = 0;
let mut backoff = RetryBackoff::default();
for (key, value, line, context) in entries {
match key {
"max" | "max_attempts" => match value {
Node::IntLiteral(value) if *value >= 0 => max_attempts = *value as u32,
_ => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line,
message: format!(
"`{context}` `max` on `{fn_name}` requires a non-negative integer; \
using the dispatcher default"
),
}),
},
"backoff" | "policy" => match value {
Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
match value.trim().to_ascii_lowercase().as_str() {
"svix" | "" => backoff = RetryBackoff::Svix,
"linear" => backoff = RetryBackoff::Linear,
"exponential" => backoff = RetryBackoff::Exponential,
other => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line,
message: format!(
"`{context}` `backoff` on `{fn_name}` got unknown strategy \
'{other}' (expected 'svix', 'linear', or 'exponential'); using 'svix'"
),
}),
}
}
_ => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line,
message: format!(
"`{context}` `backoff` on `{fn_name}` requires a string-literal \
strategy; using 'svix'"
),
}),
},
_ => diagnostics.push(ExportDiagnostic {
code: RETRY_BAD_ARGS,
line,
message: format!(
"`{context}` on `{fn_name}` got unknown field `{key}` \
(expected `max`, `max_attempts`, `backoff`, or `policy`); field ignored"
),
}),
}
}
RetrySpec {
max_attempts,
backoff,
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "exports/typed_pipeline_tests.rs"]
mod typed_pipeline_tests;