Initial commit

This commit is contained in:
Jakub Zych
2026-02-23 23:22:37 +01:00
commit 067b8fd30e
12 changed files with 567 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# sbt build artifacts
target/
project/target/
project/project/
# IDE
.idea/
.bsp/
.metals/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db

20
build.sbt Normal file
View File

@@ -0,0 +1,20 @@
lazy val root = (project in file("."))
.settings(
name := "summercms-compass",
organization := "com.golem15.summer",
version := "0.1.0-SNAPSHOT",
scalaVersion := "3.3.4",
libraryDependencies ++= Seq(
"ma.chinespirit" %% "jig" % "0.1.0",
"org.scalameta" %% "munit" % "1.0.3" % Test,
),
scalacOptions ++= Seq(
"-Wunused:all",
"-deprecation",
"-feature",
),
testFrameworks += new TestFramework("munit.Framework"),
)

1
project/build.properties Normal file
View File

@@ -0,0 +1 @@
sbt.version=1.10.7

View File

@@ -0,0 +1,49 @@
package summer.compass
import machinespir.it.jig.ConfigReader
import java.nio.file.Path
/**
* Public API for configuration management.
*
* Compass is the CMS orchestration layer on top of Jig (HOCON parsing).
* It handles file discovery, environment layering, plugin namespacing,
* and runtime overrides — similar to Laravel's Illuminate\Config.
*/
trait Compass:
// --- String-path access (dot-notation, dynamic) ---
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 via Jig ConfigReader ---
/** Load a config section into a typed case class. */
def load[T: ConfigReader](section: String): Either[String, T]
// --- Plugin config registration ---
/** Register a plugin namespace with its config directory path. */
def addNamespace(namespace: String, path: Path): Unit
// --- Runtime overrides ---
/** Set a runtime config override (highest priority, in-memory). */
def set(key: String, value: String): Unit
/** Persist runtime overrides to config/env/{env}/overrides.conf. */
def persist(): Either[String, Unit]
// --- Environment ---
def environment: String
def configPath: Path
// --- Lifecycle ---
/** Reload all config from disk, clearing caches and runtime overrides. */
def reload(): Unit

View File

@@ -0,0 +1,82 @@
package summer.compass
import machinespir.it.jig.{Config, ConfigFactory}
import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters.*
/**
* Stateless utility for config file discovery, environment detection,
* and HOCON merging with environment overlays.
*/
object ConfigDiscovery:
/** Detect environment from SUMMER_ENV env var, defaulting to "production". */
def detectEnvironment(): String =
Option(System.getenv("SUMMER_ENV")).filter(_.nonEmpty).getOrElse("production")
/**
* Scan a config directory for *.conf files.
* Returns a map of section name (filename without .conf) to file path.
*/
def discoverSections(configDir: Path): Map[String, Path] =
if !Files.isDirectory(configDir) then Map.empty
else
val builder = Map.newBuilder[String, Path]
val stream = Files.list(configDir)
try
stream.iterator.asScala
.filter(p => Files.isRegularFile(p) && p.toString.endsWith(".conf"))
.foreach { p =>
val filename = p.getFileName.toString
val section = filename.stripSuffix(".conf")
builder += section -> p
}
finally stream.close()
builder.result()
/**
* Load a single section with environment overlay.
* The env file's values override the base file's values via withFallback.
*/
def loadSection(configDir: Path, section: String, env: String): Config =
val basePath = configDir.resolve(s"$section.conf")
val envPath = configDir.resolve(s"env/$env/$section.conf")
val baseConfig = loadFile(basePath)
val envConfig = loadFile(envPath)
// env overrides base: envConfig takes priority, base is fallback
envConfig.withFallback(baseConfig)
/**
* Load all discovered sections into a single merged Config.
* Each section is namespaced under its filename:
* database.conf -> database.host, database.port, etc.
*/
def loadAll(configDir: Path, env: String): Config =
val sections = discoverSections(configDir)
sections.foldLeft(ConfigFactory.empty()) { case (acc, (section, _)) =>
val sectionConfig = loadSection(configDir, section, env)
// Wrap section config under its name as namespace
val wrapped = sectionConfig.atKey(section)
acc.withFallback(wrapped)
}
/** Load all .conf files from a plugin config directory (flat, no env overlay). */
def loadPluginConfig(pluginConfigDir: Path): Config =
if !Files.isDirectory(pluginConfigDir) then ConfigFactory.empty()
else
val stream = Files.list(pluginConfigDir)
try
stream.iterator.asScala
.filter(p => Files.isRegularFile(p) && p.toString.endsWith(".conf"))
.foldLeft(ConfigFactory.empty()) { (acc, path) =>
val config = loadFile(path)
acc.withFallback(config)
}
finally stream.close()
/** Load a single HOCON file, returning empty config if it doesn't exist. */
private def loadFile(path: Path): Config =
if Files.exists(path) then
ConfigFactory.parseFile(path.toFile)
else
ConfigFactory.empty()

View File

@@ -0,0 +1,161 @@
package summer.compass
import machinespir.it.jig.{Config, ConfigFactory, ConfigReader, read}
import java.nio.file.{Files, Path}
import java.util.concurrent.ConcurrentHashMap
import scala.util.Try
// Thread-safe implementation of [[Compass]].
//
// Override priority (highest wins):
// 1. Runtime set() overrides (in-memory ConcurrentHashMap)
// 2. overrides.conf from env dir (loaded at boot into mergedConfig)
// 3. Environment overlay (config/env/{env}/*.conf)
// 4. Base config (config/*.conf)
final class SummerCompass(
baseConfigDir: Path,
env: String = ConfigDiscovery.detectEnvironment(),
) extends Compass:
// --- Mutable state (thread-safe) ---
/** Unified config from all discovered files + env overlay + persisted overrides. */
@volatile private var mergedConfig: Config = loadMergedConfig()
/** Plugin namespace -> config directory path. */
private val namespaces = new ConcurrentHashMap[String, Path]()
/** Cached plugin configs: namespace -> Config. */
private val pluginConfigs = new ConcurrentHashMap[String, Config]()
/** Runtime overrides (highest priority). */
private val overrides = new ConcurrentHashMap[String, String]()
// --- String-path access ---
override def getString(key: String, default: String): String =
val ov = overrides.get(key)
if ov != null then ov
else
val cfg = effectiveConfig
if cfg.hasPath(key) then cfg.getString(key)
else default
override def getInt(key: String, default: Int): Int =
val ov = overrides.get(key)
if ov != null then
Try(ov.toInt).getOrElse(default)
else
val cfg = effectiveConfig
if cfg.hasPath(key) then cfg.getInt(key)
else default
override def getBoolean(key: String, default: Boolean): Boolean =
val ov = overrides.get(key)
if ov != null then
ov.toLowerCase match
case "true" | "yes" | "on" | "1" => true
case "false" | "no" | "off" | "0" => false
case _ => default
else
val cfg = effectiveConfig
if cfg.hasPath(key) then cfg.getBoolean(key)
else default
override def getOption(key: String): Option[String] =
val ov = overrides.get(key)
if ov != null then Some(ov)
else
val cfg = effectiveConfig
if cfg.hasPath(key) then Some(cfg.getString(key))
else None
override def has(key: String): Boolean =
overrides.containsKey(key) || effectiveConfig.hasPath(key)
// --- Typed access ---
override def load[T: ConfigReader](section: String): Either[String, T] =
val cfg = effectiveConfig
if !cfg.hasPath(section) then
Left(s"Config section '$section' not found")
else
val subConfig = cfg.getConfig(section)
read.config[T](subConfig).left.map(_.msg)
// --- Plugin namespaces ---
override def addNamespace(namespace: String, path: Path): Unit =
namespaces.put(namespace, path)
// Invalidate cached plugin config for this namespace
pluginConfigs.remove(namespace)
// --- Runtime overrides ---
override def set(key: String, value: String): Unit =
overrides.put(key, value)
override def persist(): Either[String, Unit] =
Try {
val envDir = baseConfigDir.resolve(s"env/$env")
Files.createDirectories(envDir)
val overridesPath = envDir.resolve("overrides.conf")
val sb = new StringBuilder()
import scala.jdk.CollectionConverters.*
for entry <- overrides.entrySet().asScala do
val key = entry.getKey.nn
val value = entry.getValue.nn
// Write each override as a HOCON key-value pair
sb.append(s"""$key = "${escapeHocon(value)}"\n""")
Files.writeString(overridesPath, sb.toString)
()
}.toEither.left.map(e => s"Failed to persist overrides: ${e.getMessage}")
// --- Environment ---
override def environment: String = env
override def configPath: Path = baseConfigDir
// --- Lifecycle ---
override def reload(): Unit =
overrides.clear()
pluginConfigs.clear()
mergedConfig = loadMergedConfig()
// --- Internal ---
/**
* Build the effective config: plugin configs merged on top of base merged config.
* Plugin registration happens at boot time, so this is not a hot path concern.
*/
private def effectiveConfig: Config =
if namespaces.isEmpty then mergedConfig
else
import scala.jdk.CollectionConverters.*
namespaces.entrySet().asScala.foldLeft(mergedConfig) { (acc, entry) =>
val ns = entry.getKey.nn
val path = entry.getValue.nn
val pluginCfg = pluginConfigs.computeIfAbsent(ns, _ => {
ConfigDiscovery.loadPluginConfig(path).atPath(ns)
})
pluginCfg.withFallback(acc)
}
/** Load all base config + env overlay + persisted overrides into one Config. */
private def loadMergedConfig(): Config =
val base = ConfigDiscovery.loadAll(baseConfigDir, env)
// Also load persisted overrides.conf if it exists
val overridesPath = baseConfigDir.resolve(s"env/$env/overrides.conf")
val persistedOverrides =
if Files.exists(overridesPath) then ConfigFactory.parseFile(overridesPath.toFile)
else ConfigFactory.empty()
// Persisted overrides take priority over base+env
persistedOverrides.withFallback(base)
/** Escape a string value for HOCON output. */
private def escapeHocon(value: String): String =
value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")

View File

@@ -0,0 +1,3 @@
name = "SummerCMS"
debug = true
url = "http://localhost"

View File

@@ -0,0 +1,3 @@
host = "localhost"
port = 5432
name = "summer_dev"

View File

@@ -0,0 +1 @@
host = "dev-db.local"

View File

@@ -0,0 +1 @@
debug = false

View File

@@ -0,0 +1,2 @@
postsPerPage = 10
showAuthor = true

View File

@@ -0,0 +1,229 @@
package summer.compass
import machinespir.it.jig.ConfigReader
import java.nio.file.{Files, Path}
class CompassSpec extends munit.FunSuite:
/** Resolve test resources/config directory from classpath. */
val testConfigDir: Path =
Path.of(getClass.getClassLoader.getResource("config/app.conf").toURI).getParent
val testPluginsDir: Path =
Path.of(getClass.getClassLoader.getResource("plugins/acme.blog/config.conf").toURI).getParent
// --- Basic string-path access ---
test("getString returns value from base config") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getString("app.name"), "SummerCMS")
}
test("getString returns default when key missing") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getString("app.nonexistent", "fallback"), "fallback")
}
test("getInt returns integer value") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getInt("database.port"), 5432)
}
test("getInt returns default when key missing") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getInt("database.missing", 3306), 3306)
}
test("getBoolean returns boolean value") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getBoolean("app.debug"), true)
}
test("getBoolean returns default when key missing") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getBoolean("app.missing", false), false)
}
test("getOption returns Some for existing key") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getOption("app.url"), Some("http://localhost"))
}
test("getOption returns None for missing key") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getOption("app.nonexistent"), None)
}
test("has returns true for existing key") {
val compass = SummerCompass(testConfigDir, env = "dev")
assert(compass.has("app.name"))
}
test("has returns false for missing key") {
val compass = SummerCompass(testConfigDir, env = "dev")
assert(!compass.has("app.nonexistent"))
}
// --- Environment layering ---
test("dev environment overrides database host") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getString("database.host"), "dev-db.local")
// Non-overridden values remain from base
assertEquals(compass.getInt("database.port"), 5432)
}
test("production environment overrides app debug") {
val compass = SummerCompass(testConfigDir, env = "production")
assertEquals(compass.getBoolean("app.debug"), false)
// Non-overridden values remain from base
assertEquals(compass.getString("app.name"), "SummerCMS")
}
test("production database stays at base values") {
val compass = SummerCompass(testConfigDir, env = "production")
assertEquals(compass.getString("database.host"), "localhost")
}
test("environment accessor returns configured env") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.environment, "dev")
}
test("configPath accessor returns base config dir") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.configPath, testConfigDir)
}
// --- Plugin namespaces ---
test("addNamespace makes plugin config accessible via dot-path") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.addNamespace("acme.blog", testPluginsDir)
assertEquals(compass.getInt("acme.blog.postsPerPage"), 10)
assertEquals(compass.getBoolean("acme.blog.showAuthor"), true)
}
test("plugin namespace does not interfere with base config") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.addNamespace("acme.blog", testPluginsDir)
// Base config still works
assertEquals(compass.getString("app.name"), "SummerCMS")
}
// --- Runtime overrides ---
test("set overrides file config") {
val compass = SummerCompass(testConfigDir, env = "dev")
assertEquals(compass.getString("app.name"), "SummerCMS")
compass.set("app.name", "OverriddenCMS")
assertEquals(compass.getString("app.name"), "OverriddenCMS")
}
test("set override shows in has") {
val compass = SummerCompass(testConfigDir, env = "dev")
assert(!compass.has("custom.new.key"))
compass.set("custom.new.key", "value")
assert(compass.has("custom.new.key"))
}
test("set override works with getOption") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.set("app.name", "Overridden")
assertEquals(compass.getOption("app.name"), Some("Overridden"))
}
test("set override for int values") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.set("database.port", "9999")
assertEquals(compass.getInt("database.port"), 9999)
}
test("set override for boolean values") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.set("app.debug", "false")
assertEquals(compass.getBoolean("app.debug"), false)
}
// --- Typed access ---
case class DatabaseConfig(host: String, port: Int, name: String) derives ConfigReader
test("load typed config from section") {
val compass = SummerCompass(testConfigDir, env = "dev")
val result = compass.load[DatabaseConfig]("database")
assert(result.isRight, s"Expected Right, got $result")
val db = result.toOption.get
assertEquals(db.host, "dev-db.local")
assertEquals(db.port, 5432)
assertEquals(db.name, "summer_dev")
}
test("load returns Left for missing section") {
val compass = SummerCompass(testConfigDir, env = "dev")
val result = compass.load[DatabaseConfig]("nonexistent")
assert(result.isLeft)
}
// --- Persist and reload ---
test("persist writes overrides to disk and reload reads them back") {
// Use a temp directory to avoid polluting test resources
val tempDir = Files.createTempDirectory("compass-test")
val configDir = tempDir.resolve("config")
Files.createDirectories(configDir)
Files.writeString(configDir.resolve("app.conf"), """name = "TestApp"""")
val compass = SummerCompass(configDir, env = "test")
assertEquals(compass.getString("app.name"), "TestApp")
// Set an override and persist
compass.set("app.name", "PersistApp")
assertEquals(compass.getString("app.name"), "PersistApp")
val persistResult = compass.persist()
assert(persistResult.isRight, s"Persist failed: $persistResult")
// Verify the overrides file was written
val overridesPath = configDir.resolve("env/test/overrides.conf")
assert(Files.exists(overridesPath))
// Reload clears in-memory overrides but persisted file is loaded
compass.reload()
assertEquals(compass.getString("app.name"), "PersistApp")
// Cleanup
deleteRecursive(tempDir)
}
test("reload clears runtime overrides") {
val compass = SummerCompass(testConfigDir, env = "dev")
compass.set("app.name", "Temporary")
assertEquals(compass.getString("app.name"), "Temporary")
compass.reload()
assertEquals(compass.getString("app.name"), "SummerCMS")
}
// --- ConfigDiscovery ---
test("ConfigDiscovery.detectEnvironment defaults to production") {
// Only reliable if SUMMER_ENV is not set in CI
// This test just verifies the method doesn't throw
val env = ConfigDiscovery.detectEnvironment()
assert(env.nonEmpty)
}
test("ConfigDiscovery.discoverSections finds conf files") {
val sections = ConfigDiscovery.discoverSections(testConfigDir)
assert(sections.contains("app"))
assert(sections.contains("database"))
assert(!sections.contains("env"))
}
// --- Helper ---
private def deleteRecursive(path: Path): Unit =
if Files.isDirectory(path) then
val stream = Files.list(path)
try stream.forEach(p => deleteRecursive(p))
finally stream.close()
Files.deleteIfExists(path)