From b19f3dd15c70d13e6ec9c6683a7c7d962e87c4dd Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Tue, 12 May 2026 08:48:24 -0700 Subject: [PATCH] feat: capture per-version sync metrics and emit detailed + summary report Adds a SyncMetrics accumulator that tracks, per artifact and per version, whether each source version was already in sync, newly synced (with asset count, bytes, and duration), or failed (with error and duration). At the end of each run main() logs a detailed block plus a summary covering total artifacts, versions in sync, versions synced, versions failed, assets copied, bytes transferred, and total duration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mavensync/MavenHttpClient.kt | 6 +- .../mavensync/MavenHttpRepository.kt | 12 +- .../mavensync/MavenSyncEngine.kt | 55 +++++-- .../cloudshiftdev/mavensync/MavenSyncMain.kt | 13 +- .../io/cloudshiftdev/mavensync/SyncMetrics.kt | 154 ++++++++++++++++++ .../DefaultMavenHttpRepositoryTest.kt | 71 +++++--- .../mavensync/DirectoryListingParserTest.kt | 11 +- .../cloudshiftdev/mavensync/ExtensionsTest.kt | 9 +- .../mavensync/FakeMavenHttpRepository.kt | 11 +- .../mavensync/MavenMetadataXmlParserTest.kt | 15 +- .../mavensync/MavenMetadataXmlTest.kt | 3 +- .../mavensync/MavenSyncEngineTest.kt | 121 ++++++++++++-- .../mavensync/SyncMetricsTest.kt | 90 ++++++++++ 13 files changed, 483 insertions(+), 88 deletions(-) create mode 100644 src/main/kotlin/io/cloudshiftdev/mavensync/SyncMetrics.kt create mode 100644 src/test/kotlin/io/cloudshiftdev/mavensync/SyncMetricsTest.kt diff --git a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpClient.kt b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpClient.kt index 45945a4..df6bd5b 100644 --- a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpClient.kt +++ b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpClient.kt @@ -30,13 +30,15 @@ internal class MavenHttpClient( httpClient.close() } - internal suspend fun upload(url: Url, file: File) { + internal suspend fun upload(url: Url, file: File): Long { val resp = httpClient.put(url) { contentType(ContentType.Application.OctetStream) setBody(LocalFileContent(file)) } - logger.info { "Uploaded $url: status=${resp.status} size=${file.length()}" } + val size = file.length() + logger.info { "Uploaded $url: status=${resp.status} size=$size" } + return size } internal suspend fun upload(url: Url, content: String) { diff --git a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpRepository.kt b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpRepository.kt index 12999cd..733e2c1 100644 --- a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpRepository.kt +++ b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenHttpRepository.kt @@ -22,9 +22,9 @@ internal interface MavenHttpRepository : AutoCloseable { includeSignatures: Boolean, ): List - suspend fun copyAsset(asset: ArtifactVersionAsset, targetRepository: MavenHttpRepository) + suspend fun copyAsset(asset: ArtifactVersionAsset, targetRepository: MavenHttpRepository): Long - suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path) + suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path): Long suspend fun releaseVersion(coordinates: Coordinates) @@ -100,14 +100,14 @@ internal class DefaultMavenHttpRepository( override suspend fun copyAsset( asset: ArtifactVersionAsset, targetRepository: MavenHttpRepository, - ) { - mavenHttpClient.download(url(asset.coordinates, asset.name)) { _, file -> + ): Long { + return mavenHttpClient.download(url(asset.coordinates, asset.name)) { _, file -> targetRepository.uploadAsset(asset, file.toPath()) } } - override suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path) { - mavenHttpClient.upload(url(asset.coordinates, asset.name), file.toFile()) + override suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path): Long { + return mavenHttpClient.upload(url(asset.coordinates, asset.name), file.toFile()) } override suspend fun releaseVersion(coordinates: Coordinates) { diff --git a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngine.kt b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngine.kt index 6aac29b..3bc262e 100644 --- a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngine.kt +++ b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngine.kt @@ -1,6 +1,8 @@ package io.cloudshiftdev.mavensync import io.github.oshai.kotlinlogging.KotlinLogging +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.TimeSource import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.coroutineScope @@ -15,6 +17,7 @@ internal class MavenSyncEngine( private val source: MavenHttpRepository, private val target: MavenHttpRepository, private val options: SyncOptions, + private val metrics: SyncMetrics, ) { @OptIn(ExperimentalCoroutinesApi::class) suspend fun sync() = coroutineScope { @@ -32,6 +35,9 @@ internal class MavenSyncEngine( val targetMetadata = target.queryArtifactMetadata(metadata.group, metadata.artifact) val sourceVersions = metadata.artifactVersions.toSet() val targetVersions = targetMetadata.artifactVersions.toSet() + val inSyncVersions = sourceVersions intersect targetVersions + metrics.recordInSync(metadata.group, metadata.artifact, inSyncVersions) + val missingVersions = sourceVersions - targetVersions if (missingVersions.isEmpty()) { logger.debug { "No missing versions for ${metadata.group}:${metadata.artifact}" } @@ -42,21 +48,38 @@ internal class MavenSyncEngine( } missingVersions .map { Coordinates(metadata.group, metadata.artifact, it) } - .forEach { coordinates -> - val assets = - source.listArtifactVersionAssets( - coordinates, - options.transferChecksums, - options.transferSignatures, - ) - - if (assets.isNotEmpty()) { - assets.forEach { asset -> source.copyAsset(asset, target) } - - target.releaseVersion(coordinates) - - delay(options.downloadDelay) - } - } + .forEach { coordinates -> syncVersion(coordinates) } + } + + private suspend fun syncVersion(coordinates: Coordinates) { + val mark = TimeSource.Monotonic.markNow() + try { + val assets = + source.listArtifactVersionAssets( + coordinates, + options.transferChecksums, + options.transferSignatures, + ) + + if (assets.isEmpty()) return + + var bytes = 0L + assets.forEach { asset -> bytes += source.copyAsset(asset, target) } + target.releaseVersion(coordinates) + metrics.recordSynced( + coordinates = coordinates, + assetCount = assets.size, + bytes = bytes, + duration = mark.elapsedNow(), + ) + + delay(options.downloadDelay) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val msg = e.message ?: e.toString() + logger.error(e) { "Failed to sync $coordinates: $msg" } + metrics.recordFailure(coordinates, msg, mark.elapsedNow()) + } } } diff --git a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncMain.kt b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncMain.kt index d44b406..4fd569b 100644 --- a/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncMain.kt +++ b/src/main/kotlin/io/cloudshiftdev/mavensync/MavenSyncMain.kt @@ -27,10 +27,17 @@ public suspend fun main(args: Array) { logger.info { "Effective configuration: $config" } - config.source.toMavenHttpRepository().use { source -> - config.target.toMavenHttpRepository().use { target -> - MavenSyncEngine(source, target, config.toSyncOptions()).sync() + val metrics = SyncMetrics() + try { + config.source.toMavenHttpRepository().use { source -> + config.target.toMavenHttpRepository().use { target -> + MavenSyncEngine(source, target, config.toSyncOptions(), metrics).sync() + } } + } finally { + val report = metrics.snapshot() + logger.info { "\n" + report.renderDetailed() } + logger.info { "\n" + report.renderSummary() } } } diff --git a/src/main/kotlin/io/cloudshiftdev/mavensync/SyncMetrics.kt b/src/main/kotlin/io/cloudshiftdev/mavensync/SyncMetrics.kt new file mode 100644 index 0000000..b3c110b --- /dev/null +++ b/src/main/kotlin/io/cloudshiftdev/mavensync/SyncMetrics.kt @@ -0,0 +1,154 @@ +package io.cloudshiftdev.mavensync + +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration +import kotlin.time.TimeSource + +internal class SyncMetrics { + private val mark = TimeSource.Monotonic.markNow() + private val artifacts = ConcurrentHashMap, Entry>() + + fun recordInSync(group: Group, artifact: Artifact, versions: Collection) { + if (versions.isEmpty()) return + entry(group, artifact).inSync.addAll(versions) + } + + fun recordSynced(coordinates: Coordinates, assetCount: Int, bytes: Long, duration: Duration) { + entry(coordinates.group, coordinates.artifact) + .synced + .add( + VersionResult.Success( + version = coordinates.artifactVersion, + assetCount = assetCount, + bytes = bytes, + duration = duration, + ) + ) + } + + fun recordFailure(coordinates: Coordinates, error: String, duration: Duration) { + entry(coordinates.group, coordinates.artifact) + .failed + .add( + VersionResult.Failure( + version = coordinates.artifactVersion, + error = error, + duration = duration, + ) + ) + } + + fun snapshot(): SyncReport { + val artifactMetrics = + artifacts.values + .map { e -> + ArtifactMetrics( + group = e.group, + artifact = e.artifact, + inSync = e.inSync.toList(), + synced = e.synced.toList(), + failed = e.failed.toList(), + ) + } + .sortedWith(compareBy({ it.group.value }, { it.artifact.value })) + return SyncReport(totalDuration = mark.elapsedNow(), artifacts = artifactMetrics) + } + + private fun entry(group: Group, artifact: Artifact): Entry = + artifacts.computeIfAbsent(group to artifact) { Entry(group, artifact) } + + private class Entry(val group: Group, val artifact: Artifact) { + val inSync: MutableList = mutableListOf() + val synced: MutableList = mutableListOf() + val failed: MutableList = mutableListOf() + } +} + +internal data class ArtifactMetrics( + val group: Group, + val artifact: Artifact, + val inSync: List, + val synced: List, + val failed: List, +) + +internal sealed interface VersionResult { + val version: ArtifactVersion + val duration: Duration + + data class Success( + override val version: ArtifactVersion, + val assetCount: Int, + val bytes: Long, + override val duration: Duration, + ) : VersionResult + + data class Failure( + override val version: ArtifactVersion, + val error: String, + override val duration: Duration, + ) : VersionResult +} + +internal data class SyncReport(val totalDuration: Duration, val artifacts: List) { + val inSyncTotal: Int = artifacts.sumOf { it.inSync.size } + val syncedTotal: Int = artifacts.sumOf { it.synced.size } + val failedTotal: Int = artifacts.sumOf { it.failed.size } + val assetsTotal: Int = artifacts.sumOf { a -> a.synced.sumOf { it.assetCount } } + val bytesTotal: Long = artifacts.sumOf { a -> a.synced.sumOf { it.bytes } } + + fun renderDetailed(): String = + buildString { + appendLine("========== SYNC METRICS (detailed) ==========") + if (artifacts.isEmpty()) { + appendLine("(no artifacts processed)") + return@buildString + } + artifacts.forEach { a -> + appendLine("${a.group.value}:${a.artifact.value}") + if (a.inSync.isNotEmpty()) { + appendLine( + " in sync (${a.inSync.size}): ${a.inSync.joinToString(", ") { it.value }}" + ) + } + if (a.synced.isNotEmpty()) { + appendLine(" synced (${a.synced.size}):") + a.synced.forEach { s -> + appendLine( + " ${s.version.value} — ${s.assetCount} assets, ${formatBytes(s.bytes)}, ${s.duration}" + ) + } + } + if (a.failed.isNotEmpty()) { + appendLine(" failed (${a.failed.size}):") + a.failed.forEach { f -> + appendLine(" ${f.version.value} — ${f.error} (${f.duration})") + } + } + } + } + .trimEnd() + + fun renderSummary(): String = buildString { + appendLine("========== SYNC METRICS (summary) ==========") + appendLine("artifacts: ${artifacts.size}") + appendLine("versions in sync: $inSyncTotal") + appendLine("versions synced: $syncedTotal") + appendLine("versions failed: $failedTotal") + appendLine("assets copied: $assetsTotal") + appendLine("bytes transferred: ${formatBytes(bytesTotal)}") + append("duration: $totalDuration") + } +} + +private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val units = listOf("KB", "MB", "GB", "TB") + var value = bytes.toDouble() / 1024.0 + var idx = 0 + while (value >= 1024.0 && idx < units.lastIndex) { + value /= 1024.0 + idx++ + } + return "%.1f %s".format(value, units[idx]) +} diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/DefaultMavenHttpRepositoryTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/DefaultMavenHttpRepositoryTest.kt index 6cf1c15..3b3ef7b 100644 --- a/src/test/kotlin/io/cloudshiftdev/mavensync/DefaultMavenHttpRepositoryTest.kt +++ b/src/test/kotlin/io/cloudshiftdev/mavensync/DefaultMavenHttpRepositoryTest.kt @@ -38,13 +38,27 @@ private class CannedListingParser(private val byUrl: Map>) : } private fun MockRequestHandleScope.respondXml(body: String) = - respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.toString())) - -private fun MockRequestHandleScope.respondHtml(body: String = "
") =
-    respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, ContentType.Text.Html.toString()))
+    respond(
+        body,
+        HttpStatusCode.OK,
+        headersOf(HttpHeaders.ContentType, ContentType.Application.Xml.toString()),
+    )
+
+private fun MockRequestHandleScope.respondHtml(
+    body: String = "
"
+) =
+    respond(
+        body,
+        HttpStatusCode.OK,
+        headersOf(HttpHeaders.ContentType, ContentType.Text.Html.toString()),
+    )
 
 private fun MockRequestHandleScope.respondNotFound() =
-    respond("not found", HttpStatusCode.NotFound, headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()))
+    respond(
+        "not found",
+        HttpStatusCode.NotFound,
+        headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()),
+    )
 
 private fun buildHttpClient(
     handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData
@@ -54,8 +68,9 @@ private fun buildHttpClient(
         expectSuccess = true
         HttpResponseValidator {
             handleResponseExceptionWithRequest { exception, _ ->
-                if (exception is ClientRequestException &&
-                    exception.response.status == HttpStatusCode.NotFound
+                if (
+                    exception is ClientRequestException &&
+                        exception.response.status == HttpStatusCode.NotFound
                 ) {
                     throw MissingContentException(exception.response, exception.message)
                 }
@@ -64,13 +79,12 @@ private fun buildHttpClient(
     }
 }
 
-private fun metadataXml(versions: List): String =
-    buildString {
-        append("com.examplefoo")
-        append("")
-        versions.forEach { append("$it") }
-        append("")
-    }
+private fun metadataXml(versions: List): String = buildString {
+    append("com.examplefoo")
+    append("")
+    versions.forEach { append("$it") }
+    append("")
+}
 
 private fun HttpRequestData.bodyText(): String = (body as TextContent).text
 
@@ -142,7 +156,8 @@ class DefaultMavenHttpRepositoryTest :
 
         context("queryArtifactMetadata") {
             test("returns empty metadata on 404") {
-                val mavenClient = autoClose(MavenHttpClient(buildHttpClient { _ -> respondNotFound() }))
+                val mavenClient =
+                    autoClose(MavenHttpClient(buildHttpClient { _ -> respondNotFound() }))
                 val repo = DefaultMavenHttpRepository(Url(REPO), mavenClient, fixedClock)
 
                 val md = repo.queryArtifactMetadata(group, artifact)
@@ -154,18 +169,21 @@ class DefaultMavenHttpRepositoryTest :
             test("parses returned XML on 200") {
                 val mavenClient =
                     autoClose(
-                        MavenHttpClient(buildHttpClient { _ -> respondXml(metadataXml(listOf("1.0", "1.1"))) })
+                        MavenHttpClient(
+                            buildHttpClient { _ -> respondXml(metadataXml(listOf("1.0", "1.1"))) }
+                        )
                     )
                 val repo = DefaultMavenHttpRepository(Url(REPO), mavenClient, fixedClock)
 
                 val md = repo.queryArtifactMetadata(group, artifact)
-                md.artifactVersions shouldBe
-                    listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1"))
+                md.artifactVersions shouldBe listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1"))
             }
         }
 
         context("releaseVersion") {
-            test("uploads metadata XML containing existing + new version with deterministic timestamp") {
+            test(
+                "uploads metadata XML containing existing + new version with deterministic timestamp"
+            ) {
                 val coords = Coordinates(group, artifact, ArtifactVersion("2.0"))
                 val requests = mutableListOf()
 
@@ -177,7 +195,8 @@ class DefaultMavenHttpRepositoryTest :
                                 when (request.method) {
                                     HttpMethod.Get -> respondXml(metadataXml(listOf("1.0")))
                                     HttpMethod.Put -> respond("", HttpStatusCode.OK)
-                                    else -> respond("unexpected", HttpStatusCode.InternalServerError)
+                                    else ->
+                                        respond("unexpected", HttpStatusCode.InternalServerError)
                                 }
                             }
                         )
@@ -214,7 +233,8 @@ class DefaultMavenHttpRepositoryTest :
                                         puts += request
                                         respond("", HttpStatusCode.OK)
                                     }
-                                    else -> respond("unexpected", HttpStatusCode.InternalServerError)
+                                    else ->
+                                        respond("unexpected", HttpStatusCode.InternalServerError)
                                 }
                             }
                         )
@@ -257,10 +277,13 @@ class DefaultMavenHttpRepositoryTest :
                     )
                 val repo = DefaultMavenHttpRepository(Url(REPO), mavenClient, fixedClock)
 
-                repo.listArtifactVersionAssets(coords, includeChecksums = false, includeSignatures = false)
+                repo.listArtifactVersionAssets(
+                    coords,
+                    includeChecksums = false,
+                    includeSignatures = false,
+                )
 
-                captured.single().toString() shouldBe
-                    "${REPO}com/example/deep/module/foo/1.0/"
+                captured.single().toString() shouldBe "${REPO}com/example/deep/module/foo/1.0/"
             }
         }
     })
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/DirectoryListingParserTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/DirectoryListingParserTest.kt
index 474385c..f5326e0 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/DirectoryListingParserTest.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/DirectoryListingParserTest.kt
@@ -26,10 +26,7 @@ class DirectoryListingParserTest :
                 )
 
             parser.parse(baseUrl, doc) shouldContainExactlyInAnyOrder
-                listOf(
-                    Url("${BASE}bar.jar"),
-                    Url("${BASE}foo/"),
-                )
+                listOf(Url("${BASE}bar.jar"), Url("${BASE}foo/"))
         }
 
         test("filters out links not anchored to the base URL") {
@@ -44,15 +41,13 @@ class DirectoryListingParserTest :
         }
 
         test("infers directory and adds trailing slash when href omits it") {
-            val doc =
-                listing("foo 2024-01-01 12:00 -\n")
+            val doc = listing("foo 2024-01-01 12:00 -\n")
 
             parser.parse(baseUrl, doc) shouldBe listOf(Url("${BASE}foo/"))
         }
 
         test("throws when listing text has the wrong number of pieces") {
-            val doc =
-                listing("foo.jar 2024-01-01 12:00\n")
+            val doc = listing("foo.jar 2024-01-01 12:00\n")
 
             val ex = shouldThrow { parser.parse(baseUrl, doc) }
             ex.cause shouldNotBe null
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/ExtensionsTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/ExtensionsTest.kt
index 1350256..08fea19 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/ExtensionsTest.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/ExtensionsTest.kt
@@ -8,7 +8,8 @@ class ExtensionsTest :
     FunSpec({
         context("Url.filename") {
             test("returns last path segment") {
-                Url("https://repo.example.com/a/b/foo-1.0.jar").filename shouldBe Filename("foo-1.0.jar")
+                Url("https://repo.example.com/a/b/foo-1.0.jar").filename shouldBe
+                    Filename("foo-1.0.jar")
             }
 
             test("returns empty Filename for directory-style URL") {
@@ -28,11 +29,13 @@ class ExtensionsTest :
 
         context("String.normalizeUrlPath") {
             test("appends trailing slash when missing") {
-                "https://repo.example.com/a/b".normalizeUrlPath() shouldBe "https://repo.example.com/a/b/"
+                "https://repo.example.com/a/b".normalizeUrlPath() shouldBe
+                    "https://repo.example.com/a/b/"
             }
 
             test("leaves trailing slash alone when present") {
-                "https://repo.example.com/a/b/".normalizeUrlPath() shouldBe "https://repo.example.com/a/b/"
+                "https://repo.example.com/a/b/".normalizeUrlPath() shouldBe
+                    "https://repo.example.com/a/b/"
             }
         }
 
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/FakeMavenHttpRepository.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/FakeMavenHttpRepository.kt
index 8ba54a9..7346bb6 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/FakeMavenHttpRepository.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/FakeMavenHttpRepository.kt
@@ -35,12 +35,19 @@ internal class FakeMavenHttpRepository(private val label: String) : MavenHttpRep
         return assets[coordinates].orEmpty()
     }
 
-    override suspend fun copyAsset(asset: ArtifactVersionAsset, targetRepository: MavenHttpRepository) {
+    var copyAssetBehavior: suspend (ArtifactVersionAsset) -> Long = { 0L }
+
+    override suspend fun copyAsset(
+        asset: ArtifactVersionAsset,
+        targetRepository: MavenHttpRepository,
+    ): Long {
         copyCalls += asset to targetRepository
+        return copyAssetBehavior(asset)
     }
 
-    override suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path) {
+    override suspend fun uploadAsset(asset: ArtifactVersionAsset, file: Path): Long {
         uploadCalls += asset to file
+        return 0L
     }
 
     override suspend fun releaseVersion(coordinates: Coordinates) {
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlParserTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlParserTest.kt
index 58835ae..c0b886e 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlParserTest.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlParserTest.kt
@@ -24,7 +24,8 @@ class MavenMetadataXmlParserTest :
                         
                       
                     
-                    """.trimIndent()
+                    """
+                        .trimIndent()
                 )
 
             val md = MavenMetadataXmlParser.parse(doc)
@@ -48,7 +49,8 @@ class MavenMetadataXmlParserTest :
                         
                       
                     
-                    """.trimIndent()
+                    """
+                        .trimIndent()
                 )
 
             MavenMetadataXmlParser.parse(doc).artifactVersions shouldBe
@@ -62,7 +64,8 @@ class MavenMetadataXmlParserTest :
                     
                       foo
                     
-                    """.trimIndent()
+                    """
+                        .trimIndent()
                 )
 
             shouldThrow { MavenMetadataXmlParser.parse(doc) }
@@ -75,7 +78,8 @@ class MavenMetadataXmlParserTest :
                     
                       com.example
                     
-                    """.trimIndent()
+                    """
+                        .trimIndent()
                 )
 
             shouldThrow { MavenMetadataXmlParser.parse(doc) }
@@ -89,7 +93,8 @@ class MavenMetadataXmlParserTest :
                       com.example
                       foo
                     
-                    """.trimIndent()
+                    """
+                        .trimIndent()
                 )
 
             MavenMetadataXmlParser.parse(doc).artifactVersions shouldBe emptyList()
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlTest.kt
index b8b47bb..471c777 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlTest.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenMetadataXmlTest.kt
@@ -35,7 +35,8 @@ class MavenMetadataXmlTest :
                 |    20240315083045
                 |  
                 |
-                |""".trimMargin()
+                |"""
+                    .trimMargin()
         }
 
         test("emits empty  block when no versions") {
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngineTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngineTest.kt
index f959e10..bd42b79 100644
--- a/src/test/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngineTest.kt
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/MavenSyncEngineTest.kt
@@ -4,22 +4,22 @@ import io.kotest.core.spec.style.FunSpec
 import io.kotest.matchers.collections.shouldBeEmpty
 import io.kotest.matchers.collections.shouldContainExactly
 import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
+import io.kotest.matchers.shouldBe
 import kotlin.time.Duration
 
-private fun defaultOptions(
-    transferChecksums: Boolean = false,
-    transferSignatures: Boolean = true,
-) = SyncOptions(
-    transferChecksums = transferChecksums,
-    transferSignatures = transferSignatures,
-    artifactConcurrency = 1,
-    crawlDelay = Duration.ZERO,
-    downloadDelay = Duration.ZERO,
-    paths = emptyList(),
-)
+private fun defaultOptions(transferChecksums: Boolean = false, transferSignatures: Boolean = true) =
+    SyncOptions(
+        transferChecksums = transferChecksums,
+        transferSignatures = transferSignatures,
+        artifactConcurrency = 1,
+        crawlDelay = Duration.ZERO,
+        downloadDelay = Duration.ZERO,
+        paths = emptyList(),
+    )
 
 private val group = Group("com.example")
 private val artifact = Artifact("foo")
+
 private fun coords(v: String) = Coordinates(group, artifact, ArtifactVersion(v))
 
 class MavenSyncEngineTest :
@@ -28,12 +28,20 @@ class MavenSyncEngineTest :
             val source = FakeMavenHttpRepository("source")
             val target = FakeMavenHttpRepository("target")
             target.seedMetadata(
-                ArtifactMetadata(group, artifact, listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")))
+                ArtifactMetadata(
+                    group,
+                    artifact,
+                    listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")),
+                )
             )
-            val engine = MavenSyncEngine(source, target, defaultOptions())
+            val engine = MavenSyncEngine(source, target, defaultOptions(), SyncMetrics())
 
             engine.handleArtifact(
-                ArtifactMetadata(group, artifact, listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")))
+                ArtifactMetadata(
+                    group,
+                    artifact,
+                    listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")),
+                )
             )
 
             source.listAssetCalls.shouldBeEmpty()
@@ -51,7 +59,7 @@ class MavenSyncEngineTest :
             source.assets[v11] = listOf(ArtifactVersionAsset(v11, Filename("foo-1.1.jar")))
             source.assets[v20] = listOf(ArtifactVersionAsset(v20, Filename("foo-2.0.jar")))
 
-            val engine = MavenSyncEngine(source, target, defaultOptions())
+            val engine = MavenSyncEngine(source, target, defaultOptions(), SyncMetrics())
 
             engine.handleArtifact(
                 ArtifactMetadata(
@@ -62,8 +70,9 @@ class MavenSyncEngineTest :
             )
 
             source.listAssetCalls.map { it.first } shouldContainExactlyInAnyOrder listOf(v11, v20)
-            source.copyCalls.map { (asset, repo) -> asset.coordinates to repo } shouldContainExactlyInAnyOrder
-                listOf(v11 to target, v20 to target)
+            source.copyCalls.map { (asset, repo) ->
+                asset.coordinates to repo
+            } shouldContainExactlyInAnyOrder listOf(v11 to target, v20 to target)
             target.releaseCalls shouldContainExactlyInAnyOrder listOf(v11, v20)
         }
 
@@ -73,7 +82,7 @@ class MavenSyncEngineTest :
             val v10 = coords("1.0")
             source.assets[v10] = emptyList()
 
-            val engine = MavenSyncEngine(source, target, defaultOptions())
+            val engine = MavenSyncEngine(source, target, defaultOptions(), SyncMetrics())
 
             engine.handleArtifact(ArtifactMetadata(group, artifact, listOf(ArtifactVersion("1.0"))))
 
@@ -93,10 +102,86 @@ class MavenSyncEngineTest :
                     source,
                     target,
                     defaultOptions(transferChecksums = true, transferSignatures = false),
+                    SyncMetrics(),
                 )
 
             engine.handleArtifact(ArtifactMetadata(group, artifact, listOf(ArtifactVersion("1.0"))))
 
             source.listAssetCalls shouldContainExactly listOf(Triple(v, true, false))
         }
+
+        test("records in-sync versions in metrics when target already has them") {
+            val source = FakeMavenHttpRepository("source")
+            val target = FakeMavenHttpRepository("target")
+            target.seedMetadata(
+                ArtifactMetadata(
+                    group,
+                    artifact,
+                    listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")),
+                )
+            )
+            val metrics = SyncMetrics()
+            val engine = MavenSyncEngine(source, target, defaultOptions(), metrics)
+
+            engine.handleArtifact(
+                ArtifactMetadata(
+                    group,
+                    artifact,
+                    listOf(ArtifactVersion("1.0"), ArtifactVersion("1.1")),
+                )
+            )
+
+            val report = metrics.snapshot()
+            report.inSyncTotal shouldBe 2
+            report.syncedTotal shouldBe 0
+            report.failedTotal shouldBe 0
+        }
+
+        test("records synced versions with asset count and bytes") {
+            val source = FakeMavenHttpRepository("source")
+            val target = FakeMavenHttpRepository("target")
+            val v11 = coords("1.1")
+            source.assets[v11] =
+                listOf(
+                    ArtifactVersionAsset(v11, Filename("foo-1.1.jar")),
+                    ArtifactVersionAsset(v11, Filename("foo-1.1.pom")),
+                )
+            source.copyAssetBehavior = { 100L }
+            val metrics = SyncMetrics()
+            val engine = MavenSyncEngine(source, target, defaultOptions(), metrics)
+
+            engine.handleArtifact(ArtifactMetadata(group, artifact, listOf(ArtifactVersion("1.1"))))
+
+            val report = metrics.snapshot()
+            report.syncedTotal shouldBe 1
+            report.assetsTotal shouldBe 2
+            report.bytesTotal shouldBe 200L
+        }
+
+        test("records failure and continues to the next version when copyAsset throws") {
+            val source = FakeMavenHttpRepository("source")
+            val target = FakeMavenHttpRepository("target")
+            val v11 = coords("1.1")
+            val v20 = coords("2.0")
+            source.assets[v11] = listOf(ArtifactVersionAsset(v11, Filename("foo-1.1.jar")))
+            source.assets[v20] = listOf(ArtifactVersionAsset(v20, Filename("foo-2.0.jar")))
+            source.copyAssetBehavior = { asset ->
+                if (asset.coordinates == v11) error("boom") else 50L
+            }
+            val metrics = SyncMetrics()
+            val engine = MavenSyncEngine(source, target, defaultOptions(), metrics)
+
+            engine.handleArtifact(
+                ArtifactMetadata(
+                    group,
+                    artifact,
+                    listOf(ArtifactVersion("1.1"), ArtifactVersion("2.0")),
+                )
+            )
+
+            val report = metrics.snapshot()
+            report.syncedTotal shouldBe 1
+            report.failedTotal shouldBe 1
+            target.releaseCalls shouldContainExactly listOf(v20)
+        }
     })
diff --git a/src/test/kotlin/io/cloudshiftdev/mavensync/SyncMetricsTest.kt b/src/test/kotlin/io/cloudshiftdev/mavensync/SyncMetricsTest.kt
new file mode 100644
index 0000000..f47bb13
--- /dev/null
+++ b/src/test/kotlin/io/cloudshiftdev/mavensync/SyncMetricsTest.kt
@@ -0,0 +1,90 @@
+package io.cloudshiftdev.mavensync
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+private val g = Group("com.example")
+private val a = Artifact("foo")
+
+private fun v(s: String) = ArtifactVersion(s)
+
+private fun c(s: String) = Coordinates(g, a, v(s))
+
+class SyncMetricsTest :
+    FunSpec({
+        test("empty snapshot has zero totals") {
+            val report = SyncMetrics().snapshot()
+            report.inSyncTotal shouldBe 0
+            report.syncedTotal shouldBe 0
+            report.failedTotal shouldBe 0
+            report.assetsTotal shouldBe 0
+            report.bytesTotal shouldBe 0L
+        }
+
+        test("aggregates in-sync, synced and failed totals across artifacts") {
+            val metrics = SyncMetrics()
+            metrics.recordInSync(g, a, listOf(v("1.0"), v("1.1")))
+            metrics.recordSynced(
+                c("1.2"),
+                assetCount = 3,
+                bytes = 1024L,
+                duration = 500.milliseconds,
+            )
+            metrics.recordSynced(c("1.3"), assetCount = 2, bytes = 2048L, duration = 1.seconds)
+            metrics.recordFailure(c("1.4"), error = "HTTP 500", duration = 200.milliseconds)
+
+            val report = metrics.snapshot()
+            report.inSyncTotal shouldBe 2
+            report.syncedTotal shouldBe 2
+            report.failedTotal shouldBe 1
+            report.assetsTotal shouldBe 5
+            report.bytesTotal shouldBe 3072L
+        }
+
+        test("groups results by artifact and preserves order of records within an artifact") {
+            val metrics = SyncMetrics()
+            metrics.recordInSync(g, a, listOf(v("1.0")))
+            metrics.recordSynced(c("1.1"), 1, 10L, 1.seconds)
+            metrics.recordSynced(c("1.2"), 1, 20L, 1.seconds)
+            metrics.recordFailure(c("1.3"), "bad", 1.seconds)
+
+            val report = metrics.snapshot()
+            report.artifacts.size shouldBe 1
+            val am = report.artifacts.single()
+            am.inSync.map { it.value } shouldContainExactlyInAnyOrder listOf("1.0")
+            am.synced.map { it.version.value } shouldBe listOf("1.1", "1.2")
+            am.failed.map { it.version.value } shouldBe listOf("1.3")
+        }
+
+        test("renderDetailed and renderSummary include the expected fields") {
+            val metrics = SyncMetrics()
+            metrics.recordInSync(g, a, listOf(v("1.0")))
+            metrics.recordSynced(c("1.1"), 2, 1024L, 1500.milliseconds)
+            metrics.recordFailure(c("1.2"), "HTTP 500", 200.milliseconds)
+
+            val report = metrics.snapshot()
+            val detailed = report.renderDetailed()
+            detailed shouldContain "com.example:foo"
+            detailed shouldContain "in sync (1)"
+            detailed shouldContain "1.0"
+            detailed shouldContain "synced (1)"
+            detailed shouldContain "1.1 — 2 assets"
+            detailed shouldContain "failed (1)"
+            detailed shouldContain "HTTP 500"
+
+            val summary = report.renderSummary()
+            summary shouldContain "artifacts:         1"
+            summary shouldContain "versions in sync:  1"
+            summary shouldContain "versions synced:   1"
+            summary shouldContain "versions failed:   1"
+            summary shouldContain "assets copied:     2"
+        }
+
+        test("renderDetailed handles an empty report") {
+            SyncMetrics().snapshot().renderDetailed() shouldContain "(no artifacts processed)"
+        }
+    })