Skip to content

UTM Link Builder — Rust source

Build campaign tracking URLs with utm_source, utm_medium and utm_campaign parameters. Bulk mode processes a whole list, presets and import round-trip existing tracking URLs, and a validator flags attribution-breaking values — runs entirely in your browser.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! utm-link-builder — campaign URL builder with canonical utm_* ordering.
//!
//! Display snippet: ports the core `build(baseUrl, params)` from the
//! TypeScript lib (src/lib/utm.ts). The lint/bulk/preset helpers live in
//! the TypeScript/Go sources.
//!
//! Semantics: strip any stale utm_* params from the base URL, keep every
//! unrelated query param in place, then append the given params in
//! canonical order. Empty params are omitted. `None` for an unparseable
//! base. Standard library only — query values pass through unescaped.

/// Canonical utm_* parameter order used on every build.
const CANONICAL: [(&str, &str); 5] = [
    ("source", "utm_source"),
    ("medium", "utm_medium"),
    ("campaign", "utm_campaign"),
    ("term", "utm_term"),
    ("content", "utm_content"),
];

pub struct UtmParams<'a> {
    pub source: Option<&'a str>,
    pub medium: Option<&'a str>,
    pub campaign: Option<&'a str>,
    pub term: Option<&'a str>,
    pub content: Option<&'a str>,
}

fn field<'a>(params: &UtmParams<'a>, name: &str) -> Option<&'a str> {
    match name {
        "source" => params.source,
        "medium" => params.medium,
        "campaign" => params.campaign,
        "term" => params.term,
        "content" => params.content,
        _ => None,
    }
}

pub fn build(base_url: &str, params: &UtmParams) -> Option<String> {
    // Split off fragment, then query; keep scheme/authority/path intact.
    let (before_frag, frag) = match base_url.split_once('#') {
        Some((head, tail)) => (head, Some(tail)),
        None => (base_url, None),
    };
    let (mut head, query) = match before_frag.split_once('?') {
        Some((head, tail)) => (head.to_string(), Some(tail.to_string())),
        None => (before_frag.to_string(), None),
    };

    // Coerce a scheme-less host into https; no host means hopeless.
    if !head.contains("://") {
        head = format!("https://{head}");
    }
    let rest = head.split_once("://").map(|(_, r)| r)?;
    if rest.is_empty() || rest.starts_with('/') {
        return None;
    }

    // Keep every unrelated query param; drop stale utm_* ones.
    let mut pairs: Vec<String> = Vec::new();
    if let Some(query) = query {
        for pair in query.split('&') {
            if pair.is_empty() {
                continue;
            }
            let key = pair.split('=').next().unwrap_or("");
            if !key.starts_with("utm_") {
                pairs.push(pair.to_string());
            }
        }
    }

    for (field_name, query_key) in CANONICAL {
        if let Some(value) = field(params, field_name) {
            if !value.is_empty() {
                pairs.push(format!("{query_key}={value}"));
            }
        }
    }

    let mut out = head;
    if !pairs.is_empty() {
        out.push('?');
        out.push_str(&pairs.join("&"));
    }
    if let Some(frag) = frag {
        out.push('#');
        out.push_str(frag);
    }
    Some(out)
}

// Example:
//   build("example.com/page?utm_source=stale&id=7", source=twitter, medium=social)
//   -> "https://example.com/page?id=7&utm_source=twitter&utm_medium=social"

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →