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

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")