diff --git a/.omp/lsp.json b/.omp/lsp.json
new file mode 100644
index 0000000..0fde758
--- /dev/null
+++ b/.omp/lsp.json
@@ -0,0 +1,12 @@
+{
+ "servers": {
+ "kotlin-lsp": {
+ "command": "kotlin-language-server",
+ "args": [],
+ "fileTypes": [".kt", ".kts"],
+ "rootMarkers": ["build.gradle", "settings.gradle"],
+ "warmupTimeoutMs": 60000
+ }
+ },
+ "idleTimeoutMs": 600000
+}
diff --git a/AGENTS.md b/AGENTS.md
index 67b6427..8271e7b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **android-backup-gui** (1614 symbols, 4022 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/CLAUDE.md b/CLAUDE.md
index b804a9b..3f4ddd2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,7 @@
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **android-backup-gui** (1614 symbols, 4022 relationships, 139 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **android-backup-gui** (1684 symbols, 4068 relationships, 146 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/app/build.gradle b/app/build.gradle
index 5ea0f7d..67281ca 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -24,8 +24,8 @@ android {
applicationId "com.example.androidbackupgui"
minSdk 24
targetSdk 34
- versionCode 14
- versionName "1.14"
+ versionCode 15
+ versionName "1.15"
}
buildFeatures {
compose = true
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..8dca888
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,1742 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/release/AndroidBackupGUI-release.apk b/app/release/AndroidBackupGUI-release.apk
new file mode 100644
index 0000000..4a6d7ee
Binary files /dev/null and b/app/release/AndroidBackupGUI-release.apk differ
diff --git a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt
index 523e960..06038ff 100644
--- a/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt
+++ b/app/src/main/java/com/example/androidbackupgui/backup/BackupOperation.kt
@@ -72,16 +72,29 @@ object BackupOperation {
// Create backup structure
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
- backupRoot.mkdirs()
+ if (!backupRoot.mkdirs() && !backupRoot.isDirectory) {
+ LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
+ return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
+ }
LogUtil.i(TAG, "backupApps: starting backup of ${apps.size} apps to ${backupRoot.absolutePath}")
// Write app list — includes ALL packages in [apps] (selected + legacy from snapshot)
val appListFile = File(backupRoot, "appList.txt")
- appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
+ try {
+ appListFile.writeText(apps.joinToString("\n") { it.packageName.value })
+ } catch (e: Exception) {
+ LogUtil.e(TAG, "backupApps: failed to write appList.txt — ${e.message}")
+ return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
+ }
// Write metadata JSON — fresh metadata for selected apps, legacy for historical apps
val metaFile = File(backupRoot, "app_details.json")
- metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
+ try {
+ metaFile.writeText(buildAppDetailsJson(apps, legacyApps))
+ } catch (e: Exception) {
+ LogUtil.e(TAG, "backupApps: failed to write app_details.json — ${e.message}")
+ return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
+ }
val backupTargets = if (includePkgs.isEmpty()) apps else apps.filter { it.packageName.value in includePkgs }
val totalCount = backupTargets.size
@@ -341,15 +354,23 @@ object BackupOperation {
?.substringBefore("\"")
?.takeIf { it.isNotBlank() }
if (value != null) {
- File(appDir, "ssaid.txt").writeText(value)
- Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
+ try {
+ File(appDir, "ssaid.txt").writeText(value)
+ Log.d(TAG, "backupSsaid: backed up SSAID for $packageName = $value")
+ } catch (e: Exception) {
+ Log.w(TAG, "backupSsaid: failed to write ssaid.txt for $packageName", e)
+ }
}
}
private suspend fun backupPermissions(packageName: String, appDir: File) {
val result = RootShell.exec("dumpsys package '${packageName.shellEscape()}' | grep -E 'granted=(true|false)'")
if (result.output.isNotBlank()) {
- File(appDir, "permissions.txt").writeText(result.output)
+ try {
+ File(appDir, "permissions.txt").writeText(result.output)
+ } catch (e: Exception) {
+ Log.w(TAG, "backupPermissions: failed to write permissions.txt for $packageName", e)
+ }
}
}
diff --git a/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt b/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt
index 7cabc91..8786d54 100644
--- a/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt
+++ b/app/src/main/java/com/example/androidbackupgui/ui/BackupScreen.kt
@@ -3,6 +3,7 @@ package com.example.androidbackupgui.ui
import android.content.Intent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
+import android.util.Log
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SortByAlpha
@@ -269,8 +270,18 @@ fun BackupScreen() {
}
}
} catch (e: Exception) {
- statusText = "备份异常: ${e.message}"
- } finally {
+ val errMsg = e.message ?: "未知错误"
+ Log.e("BackupScreen", "备份异常", e)
+ val hint = when {
+ errMsg.contains("EPERM", ignoreCase = true) || errMsg.contains("Operation not permitted", ignoreCase = true) ->
+ "写入备份目录被拒绝,请检查输出路径权限或改用内置存储"
+ errMsg.contains("EACCES", ignoreCase = true) || errMsg.contains("Permission denied", ignoreCase = true) ->
+ "权限不足,请检查存储权限"
+ else -> null
+ }
+ statusText = if (hint != null) "备份异常: ${e.message} ($hint)" else "备份异常: ${e.message}"
+ }
+ finally {
isRunning = false
try {
val stopIntent = Intent(context, BackupService::class.java).apply {
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..7560adf
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/android/util/Log.java b/app/src/test/java/android/util/Log.java
new file mode 100644
index 0000000..deb1a03
--- /dev/null
+++ b/app/src/test/java/android/util/Log.java
@@ -0,0 +1,23 @@
+package android.util;
+
+/**
+ * Test-only stub for android.util.Log.
+ * Prevents RuntimeException("Stub!") from android.jar during JVM unit tests.
+ */
+public final class Log {
+ public static int v(String tag, String msg) { return 0; }
+ public static int v(String tag, String msg, Throwable tr) { return 0; }
+ public static int d(String tag, String msg) { return 0; }
+ public static int d(String tag, String msg, Throwable tr) { return 0; }
+ public static int i(String tag, String msg) { return 0; }
+ public static int i(String tag, String msg, Throwable tr) { return 0; }
+ public static int w(String tag, String msg) { return 0; }
+ public static int w(String tag, String msg, Throwable tr) { return 0; }
+ public static int e(String tag, String msg) { return 0; }
+ public static int e(String tag, String msg, Throwable tr) { return 0; }
+ public static int wtf(String tag, String msg) { return 0; }
+ public static int wtf(String tag, String msg, Throwable tr) { return 0; }
+ public static String getStackTraceString(Throwable tr) { return ""; }
+ public static boolean isLoggable(String tag, int level) { return false; }
+ public static int println(int priority, String tag, String msg) { return 0; }
+}
diff --git a/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt
new file mode 100644
index 0000000..a489cb9
--- /dev/null
+++ b/app/src/test/java/com/example/androidbackupgui/backup/AppErrorTest.kt
@@ -0,0 +1,273 @@
+package com.example.androidbackupgui.backup
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.nulls.shouldBeNull
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.shouldBeInstanceOf
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.int
+import io.kotest.property.arbitrary.string
+import io.kotest.property.checkAll
+import java.io.IOException
+
+class AppErrorTest : FunSpec({
+
+ context("AppError.Network") {
+ test("has correct defaults") {
+ val error = AppError.Network("connection timeout")
+ error.message shouldBe "connection timeout"
+ error.cause.shouldBeNull()
+ error.retryable shouldBe true
+ }
+
+ test("preserves cause and retryable overrides") {
+ val cause = RuntimeException("DNS failed")
+ val error = AppError.Network("DNS resolution failed", cause = cause, retryable = false)
+ error.cause shouldBe cause
+ error.retryable shouldBe false
+ }
+
+ test("property: message is preserved") {
+ checkAll(Arb.string(1..200)) { msg ->
+ val error = AppError.Network(msg)
+ error.message shouldBe msg
+ }
+ }
+ }
+
+ context("AppError.Remote") {
+ test("preserves phase, cause, isNotFound, retryable") {
+ val cause = RuntimeException("underlying error")
+ val error = AppError.Remote("upload failed", "upload", cause = cause)
+ error.message shouldBe "upload failed"
+ error.phase shouldBe "upload"
+ error.cause shouldBe cause
+ error.isNotFound shouldBe false
+ error.retryable shouldBe false
+ }
+
+ test("with isNotFound=true") {
+ val error = AppError.Remote("not found", "list", isNotFound = true)
+ error.isNotFound shouldBe true
+ }
+ }
+
+ context("AppError.Shell") {
+ test("preserves command, exitCode, and stderr") {
+ val error = AppError.Shell("cp failed", "cp /a /b", 1, "permission denied")
+ error.message shouldBe "cp failed"
+ error.command shouldBe "cp /a /b"
+ error.exitCode shouldBe 1
+ error.stderr shouldBe "permission denied"
+ }
+ }
+
+ context("AppError.LocalIO") {
+ test("preserves path and optional cause") {
+ val error = AppError.LocalIO("file not found", "/data/test.txt")
+ error.message shouldBe "file not found"
+ error.path shouldBe "/data/test.txt"
+ error.cause.shouldBeNull()
+ }
+
+ test("preserves cause when provided") {
+ val cause = IOException("disk full")
+ val error = AppError.LocalIO("write failed", "/data/test.txt", cause = cause)
+ error.cause shouldBe cause
+ }
+ }
+
+ context("AppError.Restic") {
+ test("preserves exit code and stderr") {
+ val error = AppError.Restic("restic failed", 1, "permission denied")
+ error.message shouldBe "restic failed"
+ error.exitCode shouldBe 1
+ error.stderr shouldBe "permission denied"
+ }
+
+ test("property: any exit code is preserved") {
+ checkAll(Arb.int()) { code ->
+ val error = AppError.Restic("err", code, "stderr output")
+ error.exitCode shouldBe code
+ }
+ }
+ }
+
+ context("AppError.Parse") {
+ test("preserves message and detail") {
+ val error = AppError.Parse("bad json", "expected '{'")
+ error.message shouldBe "bad json"
+ error.detail shouldBe "expected '{'"
+ }
+
+ test("detail defaults to empty string") {
+ val error = AppError.Parse("bad json")
+ error.detail shouldBe ""
+ }
+ }
+
+ context("AppError.Cancelled") {
+ test("is a data object with fixed message") {
+ val error = AppError.Cancelled
+ error.message shouldBe "操作被取消"
+ // Verify singleton behavior
+ val error2 = AppError.Cancelled
+ error shouldBe error2
+ }
+ }
+
+ context("AppResult.Success") {
+ test("holds a value") {
+ val result: AppResult = AppResult.Success("hello")
+ result.isSuccess shouldBe true
+ result.isFailure shouldBe false
+ result.getOrNull() shouldBe "hello"
+ result.getOrDefault("fallback") shouldBe "hello"
+ result.getOrThrow() shouldBe "hello"
+ result.exceptionOrNull().shouldBeNull()
+ result.errorOrNull().shouldBeNull()
+ }
+
+ test("fold calls onSuccess") {
+ val result: AppResult = AppResult.Success(42)
+ val folded =
+ result.fold(
+ onSuccess = { it * 2 },
+ onFailure = { 0 },
+ )
+ folded shouldBe 84
+ }
+
+ test("map transforms value") {
+ val result: AppResult = AppResult.Success(42)
+ val mapped = result.map { it.toString() }
+ mapped shouldBe AppResult.Success("42")
+ }
+
+ test("mapError passes through success") {
+ val result: AppResult = AppResult.Success(42)
+ val mapped = result.mapError { AppError.Parse("should not happen") }
+ mapped shouldBe AppResult.Success(42)
+ }
+ }
+
+ context("AppResult.Failure via err()") {
+ test("creates failure result") {
+ val result: AppResult = err(AppError.Parse("bad json"))
+ result.isSuccess shouldBe false
+ result.isFailure shouldBe true
+ result.getOrNull().shouldBeNull()
+ result.getOrDefault("fallback") shouldBe "fallback"
+ result.errorOrNull() shouldBe AppError.Parse("bad json")
+ result.errorOrNull()?.message shouldBe "bad json"
+ }
+
+ test("exceptionOrNull returns RuntimeException with AppError message") {
+ val result: AppResult = err(AppError.Parse("bad json"))
+ val ex = result.exceptionOrNull()
+ ex.shouldBeInstanceOf()
+ ex?.message shouldBe "bad json"
+ }
+
+ test("getOrThrow throws RuntimeException") {
+ val result: AppResult = err(AppError.Parse("bad json"))
+ val ex = shouldThrow { result.getOrThrow() }
+ ex.message shouldBe "bad json"
+ }
+
+ test("wraps any AppError subtype") {
+ val errors =
+ listOf(
+ AppError.Network("net err"),
+ AppError.Remote("remote err", "connect"),
+ AppError.Shell("shell err", "ls", 1, ""),
+ AppError.LocalIO("io err", "/tmp"),
+ AppError.Restic("restic err", 1, ""),
+ AppError.Parse("parse err"),
+ AppError.Cancelled,
+ )
+ errors.forEach { error ->
+ val result: AppResult = err(error)
+ result.isFailure shouldBe true
+ result.errorOrNull()?.message shouldBe error.message
+ }
+ }
+ }
+
+ context("AppResult.Failure direct") {
+ test("holds an error") {
+ val error = AppError.Network("network error")
+ val result: AppResult = AppResult.Failure(error)
+ result.isSuccess shouldBe false
+ result.isFailure shouldBe true
+ result.errorOrNull() shouldBe error
+ }
+
+ test("fold calls onFailure") {
+ val result: AppResult = AppResult.Failure(AppError.Parse("parse failed"))
+ val folded =
+ result.fold(
+ onSuccess = { 0 },
+ onFailure = { error -> error.message.length },
+ )
+ folded shouldBe "parse failed".length
+ }
+
+ test("map passes through failure") {
+ val error = AppError.Parse("no data")
+ val result: AppResult = AppResult.Failure(error)
+ val mapped = result.map { it + 1 }
+ mapped shouldBe AppResult.Failure(error)
+ }
+
+ test("mapError transforms error") {
+ val result: AppResult = AppResult.Failure(AppError.Parse("old error"))
+ val mapped = result.mapError { AppError.Remote("mapped: ${it.message}", "transform") }
+ mapped.errorOrNull()?.message shouldBe "mapped: old error"
+ }
+ }
+
+ context("AppResult exhaustive when") {
+ test("can pattern match with is AppResult.Success") {
+ val result: AppResult = AppResult.Success("data")
+ val output =
+ when (result) {
+ is AppResult.Success -> "got: ${result.data}"
+ is AppResult.Failure -> "err: ${result.error.message}"
+ }
+ output shouldBe "got: data"
+ }
+
+ test("can pattern match with is AppResult.Failure") {
+ val result: AppResult = AppResult.Failure(AppError.Cancelled)
+ val output =
+ when (result) {
+ is AppResult.Success -> "got: ${result.data}"
+ is AppResult.Failure -> "err: ${result.error.message}"
+ }
+ output shouldBe "err: 操作被取消"
+ }
+ }
+
+ context("AppResult type inference") {
+ test("AppResult.Success with Unit") {
+ val result: AppResult = AppResult.Success(Unit)
+ result.isSuccess shouldBe true
+ result.getOrDefault(Unit) shouldBe Unit
+ }
+
+ test("AppResult.Failure with Nothing") {
+ val result: AppResult = AppResult.Failure(AppError.Cancelled)
+ result.isFailure shouldBe true
+ }
+ }
+
+ context("err function short-form") {
+ test("err() returns AppResult.Failure") {
+ val result: AppResult = err(AppError.Remote("upload failed", "upload"))
+ result.shouldBeInstanceOf()
+ (result as AppResult.Failure).error shouldBe AppError.Remote("upload failed", "upload")
+ }
+ }
+})
diff --git a/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt
new file mode 100644
index 0000000..1f456c8
--- /dev/null
+++ b/app/src/test/java/com/example/androidbackupgui/backup/ResticBinaryTest.kt
@@ -0,0 +1,13 @@
+package com.example.androidbackupgui.backup
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+class ResticBinaryTest : FunSpec({
+
+ context("ResticBinary") {
+ test("isReady returns false before prepare is called") {
+ ResticBinary.isReady() shouldBe false
+ }
+ }
+})
diff --git a/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt b/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt
new file mode 100644
index 0000000..46e8f44
--- /dev/null
+++ b/app/src/test/java/com/example/androidbackupgui/backup/ResticCommandRunnerTest.kt
@@ -0,0 +1,110 @@
+package com.example.androidbackupgui.backup
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.kotest.property.Arb
+import io.kotest.property.arbitrary.int
+import io.kotest.property.arbitrary.list
+import io.kotest.property.arbitrary.string
+import io.kotest.property.checkAll
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+class ResticCommandRunnerTest : FunSpec({
+
+ val defaultRunner = ResticCommandRunner()
+
+ context("buildCommandArgs") {
+ test("prepends default binary path") {
+ val args = defaultRunner.buildCommandArgs(listOf("init", "--json"))
+ args shouldBe listOf("restic", "init", "--json")
+ }
+
+ test("uses custom binary path") {
+ val runner = ResticCommandRunner()
+ runner.binaryPath = "/data/data/com.termux/files/usr/bin/restic"
+ val args = runner.buildCommandArgs(listOf("backup", "/sdcard"))
+ args shouldBe
+ listOf(
+ "/data/data/com.termux/files/usr/bin/restic",
+ "backup",
+ "/sdcard",
+ )
+ }
+
+ test("returns empty args list when called with empty list") {
+ val args = defaultRunner.buildCommandArgs(emptyList())
+ args shouldBe listOf("restic")
+ }
+
+ test("preserves argument order") {
+ val runner = ResticCommandRunner()
+ runner.binaryPath = "restic"
+ val args = runner.buildCommandArgs(listOf("a", "b", "c"))
+ args shouldBe listOf("restic", "a", "b", "c")
+ }
+
+ test("property: any list of string args mainatains length") {
+ checkAll(Arb.list(Arb.string(1..20), 0..10)) { inputArgs ->
+ val args = defaultRunner.buildCommandArgs(inputArgs)
+ args shouldHaveSize (inputArgs.size + 1)
+ args[0] shouldBe "restic"
+ }
+ }
+ }
+
+ context("runRestic(vararg)") {
+ test("delegates to runRestic(List) and returns failure on nonexistent binary") {
+ val runner = ResticCommandRunner()
+ runner.binaryPath = "/nonexistent/restic"
+ val result = runner.runRestic(mapOf("RESTIC_REPOSITORY" to "/tmp/repo"), "version")
+ result.exitCode shouldBe -1
+ result.stdout shouldBe ""
+ }
+ }
+
+ context("CommandResult serialization") {
+ test("serializes and deserializes correctly") {
+ val original =
+ ResticCommandRunner.CommandResult(
+ stdout = "some output",
+ stderr = "",
+ exitCode = 0,
+ )
+ val json = Json.encodeToString(original)
+ val decoded = Json.decodeFromString(json)
+ decoded.stdout shouldBe "some output"
+ decoded.stderr shouldBe ""
+ decoded.exitCode shouldBe 0
+ }
+
+ test("roundtrip property: preserves exit code") {
+ checkAll(Arb.int()) { code ->
+ val original =
+ ResticCommandRunner.CommandResult(
+ stdout = "out",
+ stderr = code.toString(),
+ exitCode = code,
+ )
+ val json = Json.encodeToString(original)
+ val decoded = Json.decodeFromString(json)
+ decoded.exitCode shouldBe code
+ decoded.stderr shouldBe code.toString()
+ }
+ }
+ }
+
+ context("ResticCommandRunner instantiation") {
+ test("default binary path is restic") {
+ defaultRunner.binaryPath shouldBe "restic"
+ }
+
+ test("can set custom binary path") {
+ val runner = ResticCommandRunner()
+ runner.binaryPath = "/custom/path/restic"
+ runner.binaryPath shouldBe "/custom/path/restic"
+ }
+ }
+})
diff --git a/ktlint.py b/ktlint.py
new file mode 100755
index 0000000..6e874b0
--- /dev/null
+++ b/ktlint.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Kotlin LSP client for code diagnostics. Collects all LSP messages."""
+import subprocess, json, sys, os, signal, time
+from pathlib import Path
+
+def run_diagnostics(project_dir: str, file_path: str, timeout: int = 60):
+ proc = subprocess.Popen(
+ ['/usr/local/bin/kotlin-language-server'],
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ cwd=project_dir, preexec_fn=lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL)
+ )
+
+ def send(msg):
+ data = json.dumps(msg).encode('utf-8')
+ proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode('utf-8'))
+ proc.stdin.write(data)
+ proc.stdin.flush()
+
+ def recv(timeout_s=5):
+ content_length = 0
+ end = time.time() + timeout_s
+ while time.time() < end:
+ if proc.poll() is not None:
+ return None
+ ready = proc.stdout.readable()
+ if not ready:
+ time.sleep(0.05)
+ continue
+ line = proc.stdout.readline()
+ if not line:
+ time.sleep(0.05)
+ continue
+ line = line.decode('utf-8', errors='replace').strip()
+ if line.startswith('Content-Length:'):
+ content_length = int(line.split(':')[1].strip())
+ elif line == '' and content_length > 0:
+ body = proc.stdout.read(content_length).decode('utf-8', errors='replace')
+ return json.loads(body)
+ return 'TIMEOUT'
+
+ all_msgs = []
+
+ send({
+ 'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
+ 'params': {
+ 'processId': os.getpid(),
+ 'capabilities': {
+ 'textDocument': {'diagnostics': {'dynamicRegistration': False}},
+ 'workspace': {'didChangeWatchedFiles': {'dynamicRegistration': False}}
+ },
+ 'rootUri': f'file://{project_dir}',
+ 'workspaceFolders': [{'uri': f'file://{project_dir}', 'name': Path(project_dir).name}]
+ }
+ })
+
+ # Read all messages until we get initialize result
+ end = time.time() + timeout
+ init_ok = False
+ while time.time() < end and not init_ok:
+ msg = recv(5)
+ if msg is None:
+ break
+ if msg == 'TIMEOUT':
+ continue
+ all_msgs.append(('init', msg))
+ if msg.get('id') == 1 and 'result' in msg:
+ init_ok = True
+
+ if not init_ok:
+ return all_msgs, f'INIT_TIMEOUT after {timeout}s'
+
+ send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
+
+ # Open file
+ file_uri = f'file://{file_path}'
+ with open(file_path) as f:
+ content = f.read()
+
+ send({
+ 'jsonrpc': '2.0', 'method': 'textDocument/didOpen',
+ 'params': {
+ 'textDocument': {
+ 'uri': file_uri, 'languageId': 'kotlin',
+ 'version': 1, 'text': content
+ }
+ }
+ })
+
+ # Collect messages for remaining time
+ end = time.time() + 30
+ while time.time() < end:
+ msg = recv(3)
+ if msg is None or msg == 'TIMEOUT':
+ continue
+ all_msgs.append(('open', msg))
+
+ # Shutdown
+ send({'jsonrpc': '2.0', 'id': 2, 'method': 'shutdown', 'params': {}})
+
+ try:
+ proc.terminate()
+ proc.wait(timeout=3)
+ except:
+ proc.kill()
+
+ return all_msgs, 'OK'
+
+if __name__ == '__main__':
+ file_path = os.path.abspath(sys.argv[1])
+ project_dir = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else os.getcwd()
+ print(f'Project: {project_dir}')
+ print(f'File: {file_path}\n')
+
+ msgs, status = run_diagnostics(project_dir, file_path)
+
+ print(f'Status: {status}')
+ print(f'Messages received: {len(msgs)}\n')
+
+ diag_count = 0
+ for phase, msg in msgs:
+ method = msg.get('method', '?')
+ if 'id' in msg:
+ method = f'response(id={msg["id"]})'
+ if 'error' in msg:
+ print(f' [{phase}] {method} ERROR: {msg["error"]}')
+ elif method == 'window/logMessage':
+ print(f' [{phase}] log: {msg.get("params",{}).get("message","")}')
+ elif method == 'window/showMessage':
+ print(f' [{phase}] show: {msg.get("params",{}).get("message","")}')
+ elif method == 'textDocument/publishDiagnostics':
+ diags = msg.get('params', {}).get('diagnostics', [])
+ diag_count += len(diags)
+ uri = msg.get('params', {}).get('uri', '')
+ print(f' [{phase}] publishDiagnostics ({len(diags)} items): {os.path.basename(uri)}')
+ for d in diags:
+ r = d.get('range', {})
+ s = r.get('start', {})
+ sev = {1:'E',2:'W',3:'I',4:'H'}.get(d.get('severity'),'?')
+ print(f' {sev} {s.get("line",0)+1}:{s.get("character",0)+1} {d.get("message","")}')
+ elif method.startswith('response'):
+ if 'result' in msg:
+ caps = msg.get('result', {}).get('capabilities', {})
+ print(f' [{phase}] {method} capabilities: {json.dumps(caps, indent=2)[:400]}')
+ else:
+ print(f' [{phase}] {method}')
+ else:
+ print(f' [{phase}] {method}: {json.dumps(msg, indent=2)[:200]}')
+
+ print(f'\nTotal diagnostics: {diag_count}')