Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ ThisBuild / organization := "app.softnetwork"

name := "softclient4es"

ThisBuild / version := "0.20.1"
ThisBuild / version := "0.20.2-SNAPSHOT"

ThisBuild / scalaVersion := scala213

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2025 SOFTNETWORK
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package app.softnetwork.elastic.client.repl

import java.util.Properties

import scala.util.Try

/** Bundle provenance disclosure (REPL.4 / #163 fix 4).
*
* The self-contained `-all` assemblies built by the softclient4es-repl packaging repo carry a
* `softclient4es-bundle-info.properties` resource at the jar root, stamping the bundle version and
* the exact pinned engine / extension versions. When that resource is present on the classpath,
* the REPL banner and the `version` meta-command disclose the bundle provenance. Plain installs
* and sbt runs have no such resource — the surface stays silent.
*/
object BundleInfo {

val ResourceName: String = "softclient4es-bundle-info.properties"

final case class Bundle(
bundleVersion: String,
engineVersion: String,
communityExtensionsVersion: String,
arrowExtensionsVersion: String,
gitSha: Option[String],
javaFloor: Option[String]
) {

/** Full disclosure line (spec wording — `version` meta-command). */
def summary: String =
s"Bundle $bundleVersion (engine $engineVersion, " +
s"community $communityExtensionsVersion, arrow-ext $arrowExtensionsVersion)"

/** Compact form for the fixed-width welcome banner (must stay within 56 visible chars). */
def bannerLine: String =
s"Bundle $bundleVersion (engine $engineVersion, " +
s"ext $communityExtensionsVersion + $arrowExtensionsVersion)"
}

/** Bundle info from the runtime classpath, if any. */
lazy val fromClasspath: Option[Bundle] = load(getClass.getClassLoader)

/** Test seam: load the bundle-info resource from an explicit ClassLoader. */
private[repl] def load(classLoader: ClassLoader): Option[Bundle] =
Option(classLoader.getResourceAsStream(ResourceName)).flatMap { is =>
try {
val props = new Properties()
props.load(is)
parse(props)
} catch {
case _: Exception => None
} finally {
Try(is.close())
}
}

private[repl] def parse(props: Properties): Option[Bundle] = {
def get(key: String): Option[String] =
Option(props.getProperty(key)).map(_.trim).filter(_.nonEmpty)
for {
bundle <- get("bundle.version")
engine <- get("engine.version")
community <- get("community.extensions.version")
arrowExt <- get("arrow.extensions.version")
} yield Bundle(
bundleVersion = bundle,
engineVersion = engine,
communityExtensionsVersion = community,
arrowExtensionsVersion = arrowExt,
gitSha = get("bundle.git.sha"),
javaFloor = get("java.floor")
)
}
}
48 changes: 37 additions & 11 deletions core/src/main/scala/app/softnetwork/elastic/client/repl/Repl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ class Repl(

private lazy val metaCommands: Set[String] = Set(
"help",
"version",
"quit",
"exit",
"tables",
Expand Down Expand Up @@ -319,6 +320,9 @@ class Repl(
case "help" =>
handleHelp(args)

case "version" =>
printVersionInfo()

case "quit" | "exit" =>
running = false

Expand Down Expand Up @@ -419,6 +423,9 @@ class Repl(
case "h" | "help" =>
handleHelp(args)

case "version" =>
printVersionInfo()

case "q" | "quit" =>
running = false

Expand Down Expand Up @@ -629,19 +636,37 @@ class Repl(
private def printWelcomeBanner(): Unit = {
val name = s"║ ${formatLigne(bold(cyan("SoftClient4ES CLI")), 56)} ║"
val ver = s"║ ${formatLigne(gray(s"Version $version"), 56)} ║"
// REPL.4 (#163 fix 4): -all bundle installs disclose their provenance right
// under the version line; plain installs have no bundle-info resource and
// the banner is unchanged. Same fixed-width box — always via formatLigne.
val bundleLine: Option[String] =
BundleInfo.fromClasspath.map(b => s"║ ${formatLigne(gray(b.bannerLine), 56)} ║")
val help = s"║ ${formatLigne(s"Type ${yellow("help")} for available commands", 56)} ║"
val quit = s"║ ${formatLigne(s"Type ${yellow("quit")} to exit", 56)} ║"
println(
s"""
|╔═══════════════════════════════════════════════════════════╗
|$name
|$ver
|║ ║
|$help
|$quit
|╚═══════════════════════════════════════════════════════════╝
|""".stripMargin
)
val lines =
Seq(
"╔═══════════════════════════════════════════════════════════╗",
name,
ver
) ++ bundleLine ++ Seq(
"║ ║",
help,
quit,
"╚═══════════════════════════════════════════════════════════╝"
)
println(lines.mkString("\n", "\n", "\n"))
}

private def printVersionInfo(): Unit = {
println(s"${bold(cyan("SoftClient4ES CLI"))} ${gray(s"version $version")}")
BundleInfo.fromClasspath.foreach { b =>
println(s"${cyan(b.summary)}")
println(s" engine: ${b.engineVersion}")
println(s" community extensions: ${b.communityExtensionsVersion}")
println(s" arrow extensions: ${b.arrowExtensionsVersion}")
b.gitSha.foreach(sha => println(s" bundle git SHA: $sha"))
b.javaFloor.foreach(floor => println(s" java floor: $floor+"))
}
}

private def formatLigne(value: String, size: Int): String = {
Expand Down Expand Up @@ -671,6 +696,7 @@ class Repl(
|${bold(cyan("Meta Commands:"))}
| ${yellow("help")} Show this help
| ${yellow("help <topic>")} Show help for SQL command or function
| ${yellow("version")} Show engine (and bundle) version info
| ${yellow("quit")} Exit the REPL
| ${yellow("tables")} List all tables
| ${yellow("pipelines")} List all pipelines
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package app.softnetwork.elastic.client.repl

import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

import java.io.{ByteArrayInputStream, InputStream}
import java.nio.charset.StandardCharsets
import java.util.Properties

/** Unit tests for BundleInfo (REPL.4 / #163 fix 4).
*
* The resource presence/absence is injected through a test ClassLoader — no test-scoped resource
* copy (and especially no `core/src/test/resources/application.conf` — REPL.2 finding).
*/
class BundleInfoSpec extends AnyWordSpec with Matchers {

private def classLoaderWith(content: Option[String]): ClassLoader =
new ClassLoader(null) {
override def getResourceAsStream(name: String): InputStream =
content match {
case Some(text) if name == BundleInfo.ResourceName =>
new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))
case _ => null
}
}

private val fullProperties: String =
"""bundle.version=0.20.2
|engine.version=0.20.1
|community.extensions.version=0.2.1
|arrow.extensions.version=0.2.2
|bundle.git.sha=abcdef1234567890
|java.floor=11
|""".stripMargin

"BundleInfo.load" should {

"return None when the bundle-info resource is absent (plain installs, sbt runs)" in {
BundleInfo.load(classLoaderWith(None)) shouldBe None
}

"parse a complete bundle-info resource" in {
val bundle = BundleInfo.load(classLoaderWith(Some(fullProperties)))
bundle shouldBe defined
bundle.get.bundleVersion shouldBe "0.20.2"
bundle.get.engineVersion shouldBe "0.20.1"
bundle.get.communityExtensionsVersion shouldBe "0.2.1"
bundle.get.arrowExtensionsVersion shouldBe "0.2.2"
bundle.get.gitSha shouldBe Some("abcdef1234567890")
bundle.get.javaFloor shouldBe Some("11")
}

"tolerate missing optional keys (git SHA, java floor)" in {
val minimal =
"""bundle.version=0.20.2
|engine.version=0.20.1
|community.extensions.version=0.2.1
|arrow.extensions.version=0.2.2
|""".stripMargin
val bundle = BundleInfo.load(classLoaderWith(Some(minimal)))
bundle shouldBe defined
bundle.get.gitSha shouldBe None
bundle.get.javaFloor shouldBe None
}

"return None when a mandatory key is missing" in {
val missingEngine =
"""bundle.version=0.20.2
|community.extensions.version=0.2.1
|arrow.extensions.version=0.2.2
|""".stripMargin
BundleInfo.load(classLoaderWith(Some(missingEngine))) shouldBe None
}

"return None when a mandatory key is blank" in {
val blankEngine =
"""bundle.version=0.20.2
|engine.version=
|community.extensions.version=0.2.1
|arrow.extensions.version=0.2.2
|""".stripMargin
BundleInfo.load(classLoaderWith(Some(blankEngine))) shouldBe None
}
}

"BundleInfo.parse" should {

"trim values" in {
val props = new Properties()
props.setProperty("bundle.version", " 0.20.2 ")
props.setProperty("engine.version", "0.20.1")
props.setProperty("community.extensions.version", "0.2.1")
props.setProperty("arrow.extensions.version", "0.2.2")
val bundle = BundleInfo.parse(props)
bundle shouldBe defined
bundle.get.bundleVersion shouldBe "0.20.2"
}
}

"BundleInfo.Bundle rendering" should {

val bundle = BundleInfo.Bundle(
bundleVersion = "0.20.2",
engineVersion = "0.20.1",
communityExtensionsVersion = "0.2.1",
arrowExtensionsVersion = "0.2.2",
gitSha = Some("abcdef1"),
javaFloor = Some("11")
)

"produce the spec disclosure line" in {
bundle.summary shouldBe "Bundle 0.20.2 (engine 0.20.1, community 0.2.1, arrow-ext 0.2.2)"
}

"keep the banner line within the 56-char fixed-width box" in {
bundle.bannerLine.length should be <= 56
bundle.bannerLine shouldBe "Bundle 0.20.2 (engine 0.20.1, ext 0.2.1 + 0.2.2)"
}
}
}
Loading
Loading