Build.rs-ing Documentation with Cuelang

Published: (January 14, 2026 at 04:06 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

Motivation

I have a small utility called tmplr whose purpose is to generate files or file sets from human‑readable templates (check it out, it’s neat!).
At version 0.0.9 I kept bumping version information in several places:

  • HELP shown with -h / --help
  • README.md (which duplicated the help text)
  • Cargo.toml
  • Local git tag (git tag v0.0.9)

Although this works, it adds unnecessary overhead. Since I’m a huge fan of CUE and already generate most of my configuration/data with it, I decided to let CUE handle the version injection and other documentation generation tasks.

Using a build.rs script

Rust allows a pre‑build script (a full Rust program) that runs before compilation. I leveraged this to:

  • Inject the help text into src/main.rs
  • Generate a full README.md
  • Generate a CHANGELOG.md based on smarter entries
  • Re‑use parts of the documentation in other places

Below is the complete build.rs used for the project.

use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;

fn main() {
    // Injects "HELP" into src/main.rs
    prepare_documentation_code();

    generate_doc("README.md", "readme.full");
    generate_doc("CHANGELOG.md", "changelog.text");

    // Re‑run the script when any file under `cue/` changes
    println!("cargo:rerun-if-changed=cue");
}

fn generate_doc(file_in_manifest: &str, eval_command: &str) {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let out_path = Path::new(&manifest_dir).join(file_in_manifest);
    let output = Command::new("cue")
        .args([
            "export",
            "-e",
            eval_command,
            "--out",
            "text",
            "./cue:documentation",
        ])
        .output()
        .expect("Failed to execute cue command");

    if !output.status.success() {
        panic!(
            "Cue {} generation failed:\n{}",
            file_in_manifest,
            String::from_utf8_lossy(&output.stderr)
        );
    }
    fs::write(&out_path, &output.stdout).expect("Failed to write generated file");
}

fn prepare_documentation_code() {
    let out_dir = env::var_os("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("generated_docs.rs");

    let output = Command::new("cue")
        .args([
            "export",
            "-e",
            "help.code",
            "--out",
            "text",
            "./cue:documentation",
        ])
        .output()
        .expect("Failed to execute cue command");

    if !output.status.success() {
        panic!(
            "Cue generation failed:\n{}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    fs::write(&dest_path, &output.stdout).expect("Failed to write generated file");
}

Injecting the generated code

The generated file (generated_docs.rs) is a Rust source fragment that gets included in src/main.rs:

include!(concat!(env!("OUT_DIR"), "/generated_docs.rs"));

Because generated_docs.rs is a build artifact, it is not checked into version control.
README.md and CHANGELOG.md, however, are checked in; they are generated directly in the project root (CARGO_MANIFEST_DIR).

Project layout

cue
├── changelog.cue
├── documentation.cue   ← root package file
├── help.cue
├── LICENSE.cue
└── readme.cue
  • documentation.cue is the root package that re‑exports the other files.
  • help.cue, changelog.cue, and readme.cue contain the actual data and transformations.

README template in CUE

The readme.cue file defines a full string that assembles the final README.md.
A simplified excerpt looks like this:

full: """
# tmplr
\(sections.tmplr)

## Why tmplr?
\(sections.why_tmplr)

# Quick Start
\(sections.quick_start)

# CLI Help
````markdown
\(help.text)

Installation

(sections.installation)

Usage (extended)

.tmplr files

(sections.tmplr_files)

Section Types

(sections.section_types)

Magic Variables

(sections.magic_variables)

CLI

(sections.cli)

Templates directory

(sections.templates_directory)

TODO

(sections.todo) """


The `CLI Help` section pulls directly from `help.text`, ensuring the README always reflects the current command‑line output without manual updates.

## Result  

After running `cargo build` (or any command that triggers the build script), the generated `README.md` and `CHANGELOG.md` are up‑to‑date and ready to be committed. The final repository contains the rendered files and the full `.cue` sources for inspection.

The entire extension took about **1.5 hours** to develop, most of which was spent porting existing Markdown content into CUE files.
Back to Blog

Related posts

Read more »

hyprKCS - Hyprland keybind manager

Overview Ever forget your own keybinds? Tired of grepping through multiple config files just to change a workspace shortcut? hyprKCS is a native, fast GUI writ...