use anyhow::{Context, Result, anyhow};
use std::collections::BTreeMap;
use super::{PublishContext, PublishOutcome};
use crate::error_code::{self, ErrorCodeExt};
pub fn run(
url: &str,
body: Option<&serde_json::Value>,
headers: &BTreeMap<String, String>,
ctx: &PublishContext<'_>,
) -> Result<PublishOutcome> {
let interpolated_url = interpolate(url, ctx)?;
if ctx.dry_run {
return Ok(PublishOutcome::DryRun);
}
let payload = match body {
Some(template) => interpolate_value(template, ctx)?,
None => default_body(ctx),
};
let agent = crate::http::agent();
let mut req = agent.post(&interpolated_url);
for (k, v) in headers {
let resolved = interpolate(v, ctx)?;
req = req.header(k, &resolved);
}
if !headers.contains_key("Content-Type") && !headers.contains_key("content-type") {
req = req.header("Content-Type", "application/json");
}
let response = req
.send_json(payload)
.with_context(|| format!("POST {interpolated_url}"))
.error_code(error_code::CONFIG_INVALID_PATH)?;
let status = response.status();
if !status.is_success() {
return Err(anyhow!(
"webhook POST {interpolated_url} returned HTTP {status}"
))
.error_code(error_code::CONFIG_INVALID_PATH);
}
Ok(PublishOutcome::Published {
url: Some(interpolated_url),
})
}
fn default_body(ctx: &PublishContext<'_>) -> serde_json::Value {
serde_json::json!({
"package": ctx.package_name,
"version": ctx.new_version,
"tag": ctx.tag,
})
}
fn interpolate(s: &str, ctx: &PublishContext<'_>) -> Result<String> {
let mut out = s.to_string();
out = out.replace("{name}", ctx.package_name);
out = out.replace("{version}", ctx.new_version);
out = out.replace("{tag}", ctx.tag);
out = resolve_env_placeholders(&out)?;
Ok(out)
}
fn interpolate_value(v: &serde_json::Value, ctx: &PublishContext<'_>) -> Result<serde_json::Value> {
match v {
serde_json::Value::String(s) => Ok(serde_json::Value::String(interpolate(s, ctx)?)),
serde_json::Value::Array(arr) => arr
.iter()
.map(|x| interpolate_value(x, ctx))
.collect::<Result<Vec<_>>>()
.map(serde_json::Value::Array),
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, val) in map {
out.insert(k.clone(), interpolate_value(val, ctx)?);
}
Ok(serde_json::Value::Object(out))
}
_ => Ok(v.clone()),
}
}
fn resolve_env_placeholders(s: &str) -> Result<String> {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("{env:") {
out.push_str(&rest[..start]);
let after_marker = &rest[start + 5..];
let end = after_marker
.find('}')
.ok_or_else(|| anyhow!("publisher webhook: unterminated `{{env:...}}` in template"))?;
let var_name = &after_marker[..end];
let value = std::env::var(var_name).map_err(|_| {
anyhow!("publisher webhook: env var `{var_name}` referenced via {{env:...}} is not set")
})?;
out.push_str(&value);
rest = &after_marker[end + 1..];
}
out.push_str(rest);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RegistryConfig;
fn make_ctx<'a>(registries: &'a BTreeMap<String, RegistryConfig>) -> PublishContext<'a> {
let pkg_path = std::path::PathBuf::from(".");
PublishContext {
package_name: "ferrflow",
package_path: Box::leak(Box::new(pkg_path)),
new_version: "5.3.0",
tag: "v5.3.0",
registries,
dry_run: true,
verbose: false,
}
}
#[test]
fn interpolate_replaces_all_placeholders() {
let registries = BTreeMap::new();
let ctx = make_ctx(®istries);
assert_eq!(
interpolate("released {name}@{version} ({tag})", &ctx).unwrap(),
"released ferrflow@5.3.0 (v5.3.0)"
);
}
#[test]
fn env_placeholder_resolves_when_set() {
unsafe {
std::env::set_var("FERRFLOW_TEST_WEBHOOK_TOKEN", "secret-123");
}
let s = resolve_env_placeholders("Bearer {env:FERRFLOW_TEST_WEBHOOK_TOKEN}").unwrap();
assert_eq!(s, "Bearer secret-123");
}
#[test]
fn env_placeholder_missing_is_an_error() {
let err = resolve_env_placeholders("Bearer {env:__FERRFLOW_NEVER_SET_TOKEN}")
.expect_err("must error");
assert!(format!("{err:?}").contains("is not set"));
}
#[test]
fn env_placeholder_unterminated_is_an_error() {
let err = resolve_env_placeholders("{env:FOO").expect_err("must error");
assert!(format!("{err:?}").contains("unterminated"));
}
#[test]
fn default_body_has_package_version_tag() {
let registries = BTreeMap::new();
let ctx = make_ctx(®istries);
let body = default_body(&ctx);
assert_eq!(body["package"], "ferrflow");
assert_eq!(body["version"], "5.3.0");
assert_eq!(body["tag"], "v5.3.0");
}
#[test]
fn dry_run_short_circuits() {
let registries = BTreeMap::new();
let ctx = make_ctx(®istries);
let outcome =
run("https://example.com/hook", None, &BTreeMap::new(), &ctx).expect("dry-run");
assert!(matches!(outcome, PublishOutcome::DryRun));
}
}