TransactionalUpdateService.kt
package com.depanalyzer.update
import com.depanalyzer.core.InputFingerprint
import com.depanalyzer.parser.ProjectType
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
data class UpdateExecutionResult(
val status: String,
val buildFile: String,
val backupFiles: List<String>,
val changedFiles: List<String>,
val applied: List<UpdateSuggestion>,
val lockfileStatus: String,
val durationMs: Long,
val warnings: List<String> = emptyList()
)
class TransactionalUpdateService(
private val updaterFactory: (ProjectType) -> BuildFileUpdater,
private val npmLockfileSynchronizer: (File) -> Unit = ::syncNpmLockfile
) {
fun apply(projectDir: File, plan: UpdatePlan, selectedIds: Set<String>): UpdateExecutionResult {
val startedAt = System.currentTimeMillis()
require(selectedIds.isNotEmpty()) { "Selecciona al menos una actualizacion" }
require(plan.inputFingerprint.isNotBlank()) { "El plan no contiene una huella de entrada" }
require(InputFingerprint.compute(projectDir.toPath(), plan.projectType) == plan.inputFingerprint) {
"El plan esta desactualizado: los archivos de dependencias cambiaron"
}
val selected = plan.suggestions.filter { it.suggestionId in selectedIds }
require(selected.size == selectedIds.size) { "El plan no contiene todas las sugerencias seleccionadas" }
val buildFile = plan.buildFile.canonicalFile
val projectRoot = projectDir.canonicalFile
require(buildFile.toPath().startsWith(projectRoot.toPath())) {
"El plan referencia un archivo fuera del proyecto"
}
val lockFile = if (plan.projectType == ProjectType.NPM) File(projectRoot, "package-lock.json")
.takeIf { it.isFile } else null
val originals = linkedMapOf(buildFile to buildFile.readBytes())
lockFile?.let { originals[it] = it.readBytes() }
val backupDir = File(projectRoot, ".depanalyzer-backups").apply { mkdirs() }
val stamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()).replace(':', '-')
val backups = originals.map { (file, bytes) ->
File(backupDir, "$stamp-${file.name}.bak").also { it.writeBytes(bytes) }
}
try {
val updater = updaterFactory(plan.projectType)
selected.forEach { suggestion ->
require(updater.applyUpdate(buildFile, suggestion)) {
"No se encontro una coincidencia editable para ${suggestion.coordinate}"
}
}
var lockStatus = "NOT_APPLICABLE"
if (plan.projectType == ProjectType.NPM) {
lockStatus = if (lockFile != null) {
npmLockfileSynchronizer(projectRoot)
"UPDATED"
} else {
"NOT_PRESENT"
}
}
val changed = originals.keys.filter { file ->
!file.readBytes().contentEquals(originals.getValue(file))
}.map { it.absolutePath }
return UpdateExecutionResult(
status = "APPLIED",
buildFile = buildFile.absolutePath,
backupFiles = backups.map { it.absolutePath },
changedFiles = changed,
applied = selected,
lockfileStatus = lockStatus,
durationMs = System.currentTimeMillis() - startedAt
)
} catch (error: Exception) {
originals.forEach { (file, bytes) -> file.writeBytes(bytes) }
throw IllegalStateException("La actualizacion fallo y se revirtieron todos los archivos: ${error.message}", error)
}
}
companion object {
private fun syncNpmLockfile(projectDir: File) {
val npm = if (System.getProperty("os.name").lowercase().contains("win")) "npm.cmd" else "npm"
val process = ProcessBuilder(
npm,
"install",
"--package-lock-only",
"--ignore-scripts",
"--no-audit",
"--no-fund"
)
.directory(projectDir)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
if (!process.waitFor(5, TimeUnit.MINUTES)) {
process.destroyForcibly()
error("npm excedio 5 minutos al sincronizar package-lock.json")
}
require(process.exitValue() == 0) { "npm no pudo sincronizar package-lock.json: ${output.takeLast(2000)}" }
}
}
}