feat(tools): collapse-consecutive-duplicates compression rule

adr-0003 §1.3 dedup: collapse runs of identical adjacent lines into `line (×N)`, order-preserving and lossless (count retained). Wired into ShellTool's default spec before HeadTail so the head/tail window holds distinct content. Safe as a default, unlike DropMatching.
This commit is contained in:
2026-06-02 13:57:57 +04:00
parent 198cf95725
commit 97ad456778
4 changed files with 60 additions and 2 deletions
@@ -1,5 +1,6 @@
package com.correx.core.tools.compression
import com.correx.core.tools.compression.CompressionRule.CollapseConsecutiveDuplicates
import com.correx.core.tools.compression.CompressionRule.DropMatching
import com.correx.core.tools.compression.CompressionRule.HeadTail
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
@@ -35,6 +36,7 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
?.let { regex -> lines.filterNot { regex.containsMatchIn(it) } }
?: lines
is HeadTail -> applyHeadTail(rule, lines)
CollapseConsecutiveDuplicates -> collapseConsecutive(lines)
}
private fun applyHeadTail(rule: HeadTail, lines: List<String>): List<String> {
@@ -44,4 +46,25 @@ class DeclarativeCompressor(private val spec: OutputCompressionSpec) : ToolOutpu
"$elided lines elided …" +
lines.takeLast(rule.tail)
}
private fun collapseConsecutive(lines: List<String>): List<String> {
if (lines.isEmpty()) return lines
val out = ArrayList<String>(lines.size)
var prev = lines.first()
var count = 1
for (line in lines.drop(1)) {
if (line == prev) {
count++
} else {
out += render(prev, count)
prev = line
count = 1
}
}
out += render(prev, count)
return out
}
private fun render(line: String, count: Int): String =
if (count > 1) "$line (×$count)" else line
}
@@ -43,4 +43,13 @@ sealed interface CompressionRule {
@Serializable
@SerialName("head_tail")
data class HeadTail(val head: Int, val tail: Int) : CompressionRule
/**
* Collapse runs of identical adjacent lines into a single line, appending an
* occurrence count (e.g. `Error: timeout (×14)`) when the run is longer than
* one. Order-preserving; never drops unique content — safe as a default.
*/
@Serializable
@SerialName("collapse_consecutive_duplicates")
data object CollapseConsecutiveDuplicates : CompressionRule
}
@@ -1,5 +1,6 @@
package com.correx.core.tools.compression
import com.correx.core.tools.compression.CompressionRule.CollapseConsecutiveDuplicates
import com.correx.core.tools.compression.CompressionRule.DropMatching
import com.correx.core.tools.compression.CompressionRule.HeadTail
import com.correx.core.tools.compression.CompressionRule.StripBlankLines
@@ -50,6 +51,20 @@ class DeclarativeCompressorTest {
assertEquals(raw, out)
}
@Test
fun `CollapseConsecutiveDuplicates collapses runs of identical adjacent lines with a count`() {
val raw = "Error: timeout\nError: timeout\nError: timeout\nok\nError: timeout"
val out = compress(OutputCompressionSpec(listOf(CollapseConsecutiveDuplicates)), raw)
assertEquals("Error: timeout (×3)\nok\nError: timeout", out)
}
@Test
fun `CollapseConsecutiveDuplicates leaves singletons and non-adjacent duplicates untouched`() {
val raw = "a\nb\na"
val out = compress(OutputCompressionSpec(listOf(CollapseConsecutiveDuplicates)), raw)
assertEquals(raw, out)
}
@Test
fun `read_file spec strips blanks then leading whitespace`() {
val spec = OutputCompressionSpec(listOf(StripBlankLines, StripLeadingWhitespace))
@@ -102,7 +117,10 @@ class DeclarativeCompressorTest {
@Test
fun `spec round-trips through JSON with every rule variant (config-reachable)`() {
val spec = OutputCompressionSpec(
rules = listOf(StripBlankLines, StripLeadingWhitespace, DropMatching("^Progress: "), HeadTail(3, 3)),
rules = listOf(
StripBlankLines, StripLeadingWhitespace, DropMatching("^Progress: "),
HeadTail(3, 3), CollapseConsecutiveDuplicates,
),
bypassOnError = false,
)
val encoded = Json.encodeToString(OutputCompressionSpec.serializer(), spec)