Initial commit
This commit is contained in:
49
src/main/scala/summer/compass/Compass.scala
Normal file
49
src/main/scala/summer/compass/Compass.scala
Normal 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
|
||||
82
src/main/scala/summer/compass/ConfigDiscovery.scala
Normal file
82
src/main/scala/summer/compass/ConfigDiscovery.scala
Normal 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()
|
||||
161
src/main/scala/summer/compass/SummerCompass.scala
Normal file
161
src/main/scala/summer/compass/SummerCompass.scala
Normal 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")
|
||||
Reference in New Issue
Block a user