commit 067b8fd30e5180854a0d0e8b32010d7808899764 Author: Jakub Zych Date: Mon Feb 23 23:22:37 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a996d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# sbt build artifacts +target/ +project/target/ +project/project/ + +# IDE +.idea/ +.bsp/ +.metals/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..dedb0b6 --- /dev/null +++ b/build.sbt @@ -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"), + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..73df629 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.7 diff --git a/src/main/scala/summer/compass/Compass.scala b/src/main/scala/summer/compass/Compass.scala new file mode 100644 index 0000000..f328dd5 --- /dev/null +++ b/src/main/scala/summer/compass/Compass.scala @@ -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 diff --git a/src/main/scala/summer/compass/ConfigDiscovery.scala b/src/main/scala/summer/compass/ConfigDiscovery.scala new file mode 100644 index 0000000..45dac57 --- /dev/null +++ b/src/main/scala/summer/compass/ConfigDiscovery.scala @@ -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() diff --git a/src/main/scala/summer/compass/SummerCompass.scala b/src/main/scala/summer/compass/SummerCompass.scala new file mode 100644 index 0000000..8812231 --- /dev/null +++ b/src/main/scala/summer/compass/SummerCompass.scala @@ -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") diff --git a/src/test/resources/config/app.conf b/src/test/resources/config/app.conf new file mode 100644 index 0000000..05e2f12 --- /dev/null +++ b/src/test/resources/config/app.conf @@ -0,0 +1,3 @@ +name = "SummerCMS" +debug = true +url = "http://localhost" diff --git a/src/test/resources/config/database.conf b/src/test/resources/config/database.conf new file mode 100644 index 0000000..3163eef --- /dev/null +++ b/src/test/resources/config/database.conf @@ -0,0 +1,3 @@ +host = "localhost" +port = 5432 +name = "summer_dev" diff --git a/src/test/resources/config/env/dev/database.conf b/src/test/resources/config/env/dev/database.conf new file mode 100644 index 0000000..2f76f4b --- /dev/null +++ b/src/test/resources/config/env/dev/database.conf @@ -0,0 +1 @@ +host = "dev-db.local" diff --git a/src/test/resources/config/env/production/app.conf b/src/test/resources/config/env/production/app.conf new file mode 100644 index 0000000..7b95e7a --- /dev/null +++ b/src/test/resources/config/env/production/app.conf @@ -0,0 +1 @@ +debug = false diff --git a/src/test/resources/plugins/acme.blog/config.conf b/src/test/resources/plugins/acme.blog/config.conf new file mode 100644 index 0000000..252df15 --- /dev/null +++ b/src/test/resources/plugins/acme.blog/config.conf @@ -0,0 +1,2 @@ +postsPerPage = 10 +showAuthor = true diff --git a/src/test/scala/summer/compass/CompassSpec.scala b/src/test/scala/summer/compass/CompassSpec.scala new file mode 100644 index 0000000..af12aa9 --- /dev/null +++ b/src/test/scala/summer/compass/CompassSpec.scala @@ -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)