Subsystem · resources

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:

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.

Curie.toml
[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.

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.

Liquid engine details

The liquid engine is powered by liquid-rust and supports the full Liquid template language, including:

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.

Curie.toml — liquid engine
[[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):

  1. project.*name, version, groupId, artifactId
  2. git.*git.commit.id (requires a git repo; referencing it outside one errors clearly)
  3. the scope's filterFiles .properties, in listed order
  4. the scope's inline […​.properties]
  5. 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