summer-compass
Configuration management module for SummerCMS. Handles config file discovery, environment layering, plugin namespacing, and runtime overrides — the Scala 3 equivalent of Laravel's Illuminate\Config.
Compass is not a config parser. Jig handles HOCON parsing and type-safe codec derivation. Compass is the CMS orchestration layer on top: where do config files live, how do environments overlay, how do plugins register their config.
Quick start
import summer.compass.*
import java.nio.file.Path
val config = SummerCompass(
baseConfigDir = Path.of("config"),
env = "production", // or omit to auto-detect from SUMMER_ENV
)
// String-path access (dot-notation)
config.getString("app.name") // => "SummerCMS"
config.getInt("database.port") // => 5432
config.getBoolean("app.debug") // => false (production override)
config.getOption("app.url") // => Some("http://localhost")
config.has("database.host") // => true
// Register a plugin's config directory
config.addNamespace("acme.blog", Path.of("plugins/acme/blog/config"))
config.getInt("acme.blog.postsPerPage") // => 10
config.getBoolean("acme.blog.showAuthor") // => true
// Runtime overrides (highest priority, in-memory)
config.set("app.name", "MyApp")
config.getString("app.name") // => "MyApp"
// Persist overrides to disk
config.persist() // writes to config/env/production/overrides.conf
// Reload everything from disk (clears runtime overrides)
config.reload()
Typed config access
Compass integrates with Jig's ConfigReader derivation for type-safe section loading:
import machinespir.it.jig.ConfigReader
case class DatabaseConfig(
host: String,
port: Int,
name: String,
) derives ConfigReader
val db = config.load[DatabaseConfig]("database")
// => Right(DatabaseConfig("db.example.com", 5432, "summer_prod"))
Returns Either[String, T] — no exceptions thrown.
Config file layout
Config files use HOCON (.conf), organized by section with environment overlays:
config/
app.conf # base app config
database.conf # base database config
env/
dev/
database.conf # dev overrides for database
production/
app.conf # production overrides for app
overrides.conf # persisted runtime overrides
Each file becomes a config section namespaced by its filename. database.conf with host = "localhost" is accessed as database.host.
Environment layering
Given SUMMER_ENV=dev, Compass merges configs with this priority (highest wins):
1. Runtime set() overrides (in-memory)
2. config/env/dev/overrides.conf (persisted overrides)
3. config/env/dev/database.conf (environment overlay)
4. config/database.conf (base config)
Example:
# config/database.conf (base)
host = "localhost"
port = 5432
# config/env/dev/database.conf (env overlay)
host = "dev-db.local"
Result:
database.host = "dev-db.local" # overridden by env/dev
database.port = 5432 # from base
Merging uses sconfig's withFallback — nested HOCON objects merge correctly, not just top-level keys.
Environment detection
Compass reads the SUMMER_ENV environment variable. Defaults to "production" if unset or empty. Can also be passed explicitly:
SummerCompass(configDir, env = "staging")
Plugin namespaces
Plugins register their config directory at boot time. All .conf files in that directory are loaded and namespaced under the plugin identifier:
config.addNamespace("acme.blog", Path.of("plugins/acme/blog/config"))
// plugins/acme/blog/config/config.conf contains: postsPerPage = 10
config.getInt("acme.blog.postsPerPage") // => 10
Plugin configs are cached after first access. Calling addNamespace again for the same namespace invalidates the cache.
Runtime overrides
set() creates in-memory overrides with the highest priority — they win over any file-based config:
config.set("database.host", "override.local")
config.getString("database.host") // => "override.local"
persist() writes all current overrides to config/env/{env}/overrides.conf. These persist across restarts (loaded during boot as part of the merged config).
reload() clears all runtime overrides and re-reads everything from disk.
API reference
trait Compass:
// String-path access
def getString(key: String, default: String = ""): String
def getInt(key: String, default: Int = 0): Int
def getBoolean(key: String, default: Boolean = false): Boolean
def getOption(key: String): Option[String]
def has(key: String): Boolean
// Typed access
def load[T: ConfigReader](section: String): Either[String, T]
// Plugin registration
def addNamespace(namespace: String, path: java.nio.file.Path): Unit
// Runtime overrides
def set(key: String, value: String): Unit
def persist(): Either[String, Unit]
// Environment
def environment: String
def configPath: java.nio.file.Path
// Lifecycle
def reload(): Unit
Module structure
src/main/scala/summer/compass/
Compass.scala Public API trait
ConfigDiscovery.scala Stateless file scanning, env detection, HOCON merging
SummerCompass.scala Thread-safe implementation (ConcurrentHashMap + @volatile)
Thread safety
SummerCompass is thread-safe:
ConcurrentHashMapfor plugin namespaces, plugin config cache, and runtime overrides@volatilefor the merged config reference (swapped atomically onreload())computeIfAbsentfor lazy plugin config loading
Dependencies
| Dependency | Version | Purpose |
|---|---|---|
ma.chinespirit:jig |
0.1.0 | HOCON parsing (via sconfig) + type-safe ConfigReader derivation |
org.scalameta:munit |
1.0.3 | Testing (test scope only) |
No effect systems. No Cats, no ZIO. Pure direct-style Scala 3 on JDK 21.
Building
sbt compile # compile
sbt test # run all 28 tests
Requires JDK 21+ and sbt 1.10+.
Roadmap
- Phase 2 — Integration: wire into SummerCMS boot sequence, auto-discover plugin configs during plugin registration
- Phase 3 — Admin UI: config editor in admin backend, schema validation against typed config classes
- Phase 4 —
summer compass:cachecommand to compile all config into a single cached file for production
License
Part of SummerCMS.