Skip to main content

Crate md_tmpl

Crate md_tmpl 

Source
Expand description

§md-tmpl

Strongly-typed prompt templates for LLMs — markdown files with YAML frontmatter, validated at build time via proc macros, with a full runtime API for dynamic loading.

use md_tmpl_macros::include_template;

// Parses and validates the template at build time, generates typed structs + enums.
include_template!("prompts/task_report.tmpl.md");

// Generated types:
//   task_report::Params                  — typed struct
//   task_report::ParamsPriority          — enum(Critical, High, Medium, Low)
//   task_report::ParamsTasksItem         — struct { name, urgency }
//   task_report::ParamsTasksItemUrgency  — enum(Critical, High, Medium, Low)

let params = task_report::Params::builder()
    .title("Deploy v2.0")
    .priority(task_report::ParamsPriority::Critical)
    .tasks([
        task_report::ParamsTasksItem::builder()
            .name("run migrations")
            .urgency(task_report::ParamsTasksItemUrgency::High)
            .build(),
        task_report::ParamsTasksItem::builder()
            .name("update load balancer")
            .urgency(task_report::ParamsTasksItemUrgency::Medium)
            .build(),
    ])
    .build();

let output = params.render().unwrap();
assert!(output.contains("# Task Report: Deploy v2.0"));
assert!(output.contains("Priority: Critical"));
assert!(output.contains("run migrations (High)"));

The template behind it — a plain .tmpl.md markdown file:

---
name: task_report
description: A task report template with types
types:
  - Priority = enum(Critical, High, Medium, Low)

params:
  - title = str
  - priority = Priority
  - tasks = list(name = str, urgency = Priority)
---

# Task Report: {{ title }}

Priority: {{ kind(priority) }}

> {% for task in tasks %}

- {{ task.name }} ({{ kind(task.urgency) }})

> {% /for %}

Rename a variant, add a field, remove a param — the compiler catches it immediately. No runtime surprises.

§Why?

  • Build-time validation — proc macros parse and validate syntax, types, and variable references at cargo build. Typos, missing fields, and type mismatches are build errors. Templates can also be loaded and validated at runtime.
  • Markdown-native — prompts live in .tmpl.md files, readable in any editor or on GitHub. Compound types use () (never <>), control-flow tags use > {% %} blockquote prefixes.
  • Agent-safe — when an LLM edits prompts, the compiler catches drift immediately. validate_template() enables hot-reload with contract enforcement.

§Installation

cargo add md-tmpl
# build-time validation + code generation:
cargo add md-tmpl-macros

MSRV: 1.85 (Rust 2024 edition) · no_std compatible (disable default std feature)

§Build-Time Typed Structs

§include_template!

Reads a .tmpl.md file at build time, validates it, and generates a typed module:

use md_tmpl_macros::include_template;

// Generates: pub mod simple_greeting { pub struct Params { pub name: String } }
include_template!("prompts/simple_greeting.tmpl.md");

let output = simple_greeting::Params { name: "world".into() }.render().unwrap();
assert_eq!(output, "\nHello world!\n");

§template!

Inline template strings — same validation, no file needed:

md_tmpl_macros::template!(r#"
---
params:
  - name = str
---
Hello {{ name }}!
"# => greeting);

let output = greeting::Params { name: "World".into() }
    .render()
    .unwrap();
assert_eq!(output, "Hello World!\n");

§TypedBuilder Integration

Enable typed-builder for ergonomic builder patterns:

cargo add md-tmpl --features typed-builder
cargo add md-tmpl-macros --features typed-builder
let params = greeting::Params::builder()
    .name("Alice")       // setter(into): accepts &str or String
    .count(42)
    .build();            // `items` defaults to vec![]

let output = params.render().unwrap();
Field typeBuilder behaviour
Stringsetter(into) — accepts &str, String, or anything Into<String>
Vec<…>default — omit the field to get an empty Vec
Scalars (i64, f64, bool)Required

Sub-structs also derive TypedBuilder:

let item = greeting::ParamsItemsItem::builder()
    .label("write docs")
    .build();

let params = greeting::Params::builder()
    .name("Alice")
    .count(1)
    .items(vec![item])
    .build();

§serde Integration

Render directly from any Serialize struct:

cargo add md-tmpl --features serde
use md_tmpl::Template;
use serde::Serialize;

#[derive(Serialize)]
struct ReviewParams {
    file_path: String,
    severity: String,
    findings: Vec<Finding>,
}

#[derive(Serialize)]
struct Finding { line: i64, message: String }

let tmpl = Template::from_source("\
---
params:
  - file_path = str
  - severity = str
  - findings = list(line = int, message = str)
---


Severity: {{ severity }}

> {% for finding in findings %}

- Line {{ finding.line }}: {{ finding.message }}

> {% /for %}"
).unwrap();

let output = tmpl.render(&ReviewParams {
    file_path: "main.rs".into(),
    severity: "high".into(),
    findings: vec![
        Finding { line: 42, message: "unused variable".into() },
    ],
}).unwrap();

§Runtime API

For dynamic or scripting use cases, parse templates at runtime.

§ctx! Macro

Ergonomic context construction with nested structs and lists:

use md_tmpl::{ctx, Template};

let tmpl = Template::from_source("
---
params:
  - tasks = list(title = str, priority = str)
---

> {% for task in tasks %}

- **{{ task.title }}** ({{ task.priority }})

> {% /for %}"
).unwrap();

let output = tmpl.render_ctx(&ctx! {
    tasks: [
        { title: "Write documentation", priority: "High" },
        { title: "Add unit tests",      priority: "Medium" },
    ]
}).unwrap();

assert_eq!(output, "- **Write documentation** (High)\n- **Add unit tests** (Medium)\n");

§Runtime Loading

use md_tmpl::load_template;

let tmpl = load_template(std::path::Path::new("prompts"), "simple_greeting").unwrap();

let mut ctx = md_tmpl::Context::new();
ctx.set("name", "world");
let output = tmpl.render_ctx(&ctx).unwrap();
assert!(output.contains("Hello world!"));

§Hot-Reload

Load templates from disk at runtime while keeping type safety — iterate on prompt wording without recompiling:

let tmpl = md_tmpl::Template::from_file(
    std::path::Path::new("prompts/greeting.tmpl.md")
).unwrap();
greeting::Params::validate_template(&tmpl).unwrap();

let output = greeting::Params {
    name: "Bob".into(),
    count: 1,
    items: vec![],
}.render_reloaded(&tmpl).unwrap();

§Caching

TemplateCache hashes file contents — unchanged files return cached compilations. render_cached() extends this to included templates:

use md_tmpl::TemplateCache;

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("greeting.tmpl.md");
std::fs::write(&path, "\
---
params:
  - name = str
---
Hello {{ name }}!"
).unwrap();

let cache = TemplateCache::new();
let tmpl = cache.load(&path).unwrap();

let mut ctx = md_tmpl::Context::new();
ctx.set("name", "world");
let output = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
assert_eq!(output, "Hello world!");

§Defaults & Extra Params

§render_allowing_extra()

Extra context keys not declared in frontmatter are silently ignored:

use md_tmpl::{ctx, Template};

let tmpl = Template::from_source("\
---
params:
  - name = str
---
Hello {{ name }}!"
).unwrap();
let ctx = ctx! { name: "world", extra_key: "ignored" };
assert_eq!(tmpl.render_ctx_allowing_extra(&ctx).unwrap(), "Hello world!");

§defaults_context()

Returns a Context pre-filled with default values:

use md_tmpl::Template;

let tmpl = Template::from_source("\
---
params:
  - name = str
  - count = int := 5
---
{{ name }} ({{ count }})"
).unwrap();
let mut ctx = tmpl.defaults_context();
ctx.set("name", "Alice"); // count already has default 5
assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "Alice (5)");

§Performance

§Internal Benchmarks (Criterion)

OperationSmallMediumLarge
render196 ns1.11 µs22.1 µs
parse3.65 µs15.3 µs30.8 µs

§vs Competitors

Criterion benchmarks, render only (pre-parsed template + data → output). (source)

Scenariomd-tmplTeraMiniJinjaHandlebars
simple130 ns 🏆213 ns558 ns632 ns
loop445 ns 🏆618 ns2.00 µs2.85 µs
conditional173 ns 🏆348 ns625 ns1.16 µs
hero2.07 µs 🏆2.09 µs7.62 µs21.4 µs
mega10.1 µs 🏆11.1 µs30.1 µs84.7 µs

Intel Xeon @ 2.60 GHz, 3 runs × 100 Criterion samples. Hero/mega margins are small — treat as comparable to Tera.

just bench-rust          # run Criterion benchmarks
just bench-update-rust   # run + update this table

§Full Reference

See SPEC.md for the complete syntax — control-flow tags, filters, built-in functions, whitespace control, and error diagnostics.

§License

Apache-2.0 OR MIT

Modules§

consts
Template grammar constants, syntax characters, and utility functions.

Macros§

ctx
Construct a Context with JSON-like syntax.

Structs§

CompileOptions
Configuration for template compilation.
Context
Template rendering context — holds all variables available during rendering.
DeError
Error type for Value-to-Deserialize conversion.
Frontmatter
Parsed YAML frontmatter from a .tmpl.md file.
Import
A template import declaration: [stem](path.tmpl.md).
ImportedNamespace
Resolved namespace from an imported template.
SerError
Error type for serde-to-Value conversion.
SyntaxError
A structured syntax error with optional line number and source context.
Template
A parsed template ready for rendering.
TemplateCache
A template compilation cache parameterised over a BuildHasher for content-addressed invalidation.
TypeCheckError
Structured error from VarType::check with the path to the mismatch.
ValueTypeError
Error returned when a Value is the wrong variant for a conversion.
VarDecl
A variable declaration: name + type + optional default.
VariantDecl
A variant declaration inside an enum type.

Enums§

TemplateError
Errors produced by the template engine.
Value
A value that can be inserted into a template.
VarType
Expected type of a template variable.

Constants§

BUILTIN_TYPE_NAMES
Names of all built-in types. Used for shadowing checks in validation.

Functions§

extract_template_stem
Extract the template stem (filename without extensions) from a path.
from_value
Convert a Value back into any Deserialize type.
load_template
Load a named template from a directory.
parse_frontmatter
Parse YAML frontmatter delimited by --- lines.
parse_type_annotation
Parses a type annotation string into a VarType.
resolve_imports
Resolve imports by reading referenced template files and extracting their type information.
strip_frontmatter
Strip YAML frontmatter delimited by --- and return only the body text.
to_pascal_case
Convert a snake_case, kebab-case, or other string to PascalCase.
to_value
Convert any Serialize type into a Value.