Resource filtering
Curie copies resources verbatim by default — the zero-copy fast path. Opt in to filtering to substitute build-time values (project version, git commit, declared properties, environment) into text resources. It is the modern, collision-safe replacement for Maven's maven-resources-plugin <filtering>.
Why not just copy Maven
Maven's filtering has three footguns curie deliberately avoids:
- Global
${}delimiter mangles Spring placeholders, shell/conf interpolation, and JS template literals that live in the same files. Curie defaults to@var@, which is rare in real payloads. - Silent binary corruption — Maven can substitute inside images and archives. Curie is binary-safe by extension list and content sniffing.
- Silent
${unset}shipping to prod — an unresolved placeholder leaks through. Curie fails the build by default.
Two independent scopes
Production and test resources are configured separately by two sections of the same shape — [resources] and [test-resources]. Each has its own source directories, filter stages, and variables, so you can filter test fixtures differently from (or without) filtering production resources. A scope is active when it declares filter stages or custom source directories; otherwise that scope keeps the verbatim copy path.
[resources] directories = ["src/main/resources", "src/main/config"] # merged; later wins on collision properties = { "api.url" = "https://api.example.com" } # scope variables filterFiles = ["build.properties"] # external vars (.properties) nonFilteredExtensions = ["bin"] # extra binary types # Stage 1: @var@ substitution, only over config files from one root. [[resources.filter]] engine = "substitute" directories = ["src/main/config"] # optional: a subset of the roots above includes = ["**/*.properties", "**/*.yml", "**/*.xml"] excludes = ["**/secret.*"] [resources.filter.substitute] # engine-specific options delimiter = "@..@" failOnUnresolved = true # Test resources: independent — own dirs, own (or no) filtering. [test-resources] directories = ["src/test/resources"] [[test-resources.filter]] engine = "substitute" includes = ["**/test.properties"]
The stage pipeline
Within a scope, filtering is an ordered list of stages ([[resources.filter]]). Each stage picks an engine, the files it applies to (include/exclude globs), an optional subset of origin directories, and that engine's own nested options. Stages run in declaration order and chain on the same file — a file matched by two stages is rendered by the first, then its output feeds the second. A file matched by no stage is copied verbatim.
- includes / excludes — ant-style globs (
**crosses/,*stays within a segment,?one char). Emptyincludesmeans every non-binary file;excludeswin overincludes. - directories — restrict a stage to files originating from a subset of the scope's source roots. Must be a subset of the scope's
directories. Empty means all roots.
Pluggable engines
The per-file text transform sits behind a trait, so new mechanisms drop in without touching discovery, the dir walk, binary safety, or incremental tracking.
substitute— fast, dependency-free@var@replacement. Maven-syncable.liquid— richer Liquid templating ({{ project.version }},{% if %}, filters). Uses its own{{ }}/{% %}syntax — no delimiter knob needed. Variables are exposed as nested objects, soproject.versionis accessed as{{ project.version }}. Environment variables are available under{{ env.VAR_NAME }}. Has no maven-resources-plugin equivalent;curie maven syncrejects liquid stages with a clear error.
Liquid engine details
The liquid engine is powered by liquid-rust and supports the full Liquid template language, including:
- Variable output:
{{ project.version }},{{ git.commit.id }} - Filters:
{{ project.name | upcase }},{{ value | default: "fallback" }} - Conditionals:
{% if project.version %}...{% endif %} - Assignments:
{% assign name = project.name %}
Undefined variables are a hard error (liquid-rust uses strict mode). This matches the substitute engine's failOnUnresolved = true default — no silent blanks leak into production. Use the default filter ({{ maybe_set | default: "fallback" }}) when a value is genuinely optional.
[[resources.filter]] engine = "liquid" includes = ["**/*.yml", "**/*.properties"]
Delimiter & escaping
The delimiter is written Maven-style as a single string in which .. marks the placeholder name: the default "@..@" means @name@, and "${..}" means ${name}. The text before .. is the begin token, the text after is the end token. The scanner finds the begin token, then the next end token; the trimmed inner slice is the variable name. A doubled begin token escapes to a literal (@@ → @) and does not start a placeholder. Set delimiter = "${..}" when you actually want Maven-style ${} syntax.
Variables & precedence
Each scope gets a flat variable map: a shared base plus the scope's own variables. Precedence, low → high (later wins):
project.*—name,version,groupId,artifactIdgit.*—git.commit.id(requires a git repo; referencing it outside one errors clearly)- the scope's
filterFiles.properties, in listed order - the scope's inline
[….properties] env.*— any environment variable, resolved on reference
project.*/git.*/env.* are identical for both scopes; properties/filterFiles are scope-local, so main and test can bind the same key differently. An unresolved @var@ is a hard build error naming the file and token, unless that stage sets failOnUnresolved = false (then the placeholder passes through verbatim).
Binary safety
Two layers prevent corruption. First, a built-in extension allowlist (images, archives, fonts, class files, media, keystores…) plus your nonFilteredExtensions marks files as binary and copies them byte-for-byte. Second, any file with a NUL byte in its first ~8 KB (or invalid UTF-8) is treated as binary regardless of extension. Binary files bypass every stage.
The processed-dir swap
When a scope is active, curie materializes a merged/filtered output directory under target/ (target/resources, target/test-resources) and rebinds every downstream consumer — the JAR, fat JAR, run, test, and dev — to read it instead of the raw sources. run and dev see identical values. Source JARs deliberately stay on the raw sources (they ship unfiltered originals).
Incremental behavior
Re-filtering is driven by a value fingerprint hashed over every resolved variable, every stage's config, the resolved source-root list, and filter-file mtimes — plus a standard source-mtime check. So a version bump in Curie.toml re-filters even though no resource file was touched, and the JAR repackage cascades naturally off the rewritten output's mtimes. An unchanged build stays zero-work.
Maven sync fidelity
curie maven sync maps a scope cleanly only when its pipeline is one substitute stage (the common case): it emits <resources>/<testResources> per directory with <filtering>, the includes/excludes, a maven-resources-plugin <delimiter> override (so a Maven build substitutes byte-identically), and the scope properties/filters. Multiple source directories map fine — Maven supports multiple <resource> entries.
For anything Maven cannot faithfully reproduce — a liquid stage, or multiple chained stages in either scope — curie maven sync fails with a hard error naming the offending scope, rather than emitting a misleading POM that would ship differently-filtered resources. Silent metadata loss is exactly the divergence sync exists to prevent.
Examples
examples/resource-filtering-demo/—substituteengine: two merged source directories, a stage scoped to one of them, a binary file copied untouched, and an independent[test-resources]scope filtering a test fixture.examples/liquid-filtering-demo/—liquidengine: Liquid templating with conditionals, filters (upcase,downcase), and nested variable access. Shows how the liquid and substitute engines can coexist across different scopes or stages.