diff --git a/README.md b/README.md new file mode 100644 index 0000000..a14382c --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# summer-compass + +Configuration management module for [SummerCMS](https://git.golem15.com/golem15/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](https://github.com/lbialy/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 + +```scala +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: + +```scala +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](https://github.com/lightbend/config/blob/main/HOCON.md) (`.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: + +```hocon +# 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: + +```scala +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: + +```scala +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: + +```scala +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 + +```scala +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: + +- `ConcurrentHashMap` for plugin namespaces, plugin config cache, and runtime overrides +- `@volatile` for the merged config reference (swapped atomically on `reload()`) +- `computeIfAbsent` for 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 + +```bash +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:cache` command to compile all config into a single cached file for production + +## License + +Part of SummerCMS.