Using Ollama and Kotlin to migrate multiple files into a new library

At work, there is a need to migrate our project from using LoganSquare to kotlinx.serialization.

Part of the work involves replacing the annotations the first library is using with the ones from the second. Unfortunately some cases are not as simple as replacing foo with boo. For example, a property must be annotated with @JsonField(name = ["a_name_here"]) in LoganSquare and @SerialName("a_name_here") is kotlinx.

So, I had to decide:

  1. Do I spend 2-3 hours and migrate 100+ files manually, one by one?
  2. Do I cut the hours in half by using the search and replace tool and then fix anything the tool couldn’t manage?
  3. Do I start a journey of unknown number of hours to figure out how to perform the migration using a local LLM?

Ollama

Yeap, I chose to go with number three! And to do that I started by installing Ollama. Ollama is a tool that allows you to download open source LLMs and start playing with them locally. All prompts are handled in your device and there is no network activity.

You can either download it from its site or, if you are on macOS, use brew: brew install ollama.

After that you can run one of the models it provides, ex: ollama run llama3.2
or fire up the server it comes with and start playing with its API: ollama serve

Kotlin

The flow is simple:

  • Load in memory, one by one, the contents of the files that must be migrated
  • Provide each content along side with a prompt to an LLM
  • Store the LLM’s result to the file
  • (optional) Start dancing for building your first LLM based workflow

Reading the contents and writing them back to the files is easy with Kotlin. Communicating with the ollama server is also easy when using OkHttp and kotlinx.serialization. Believe it or not the most time consuming part was figuring out the prompt!

After a lot of attempts the one prompt that managed to produced the best result was the one where I listed the steps that I would have done manually:

We have a file written in Kotlin and we need to migrate it from LoganSquare to KotlinX Serialization.

To do that we have to replace:
- "import com.bluelinelabs.logansquare.annotation.JsonField" with "import kotlinx.serialization.SerialName"
- "import com.bluelinelabs.logansquare.annotation.JsonObject" with "import kotlinx.serialization.Serializable"
- "@JsonObject\ninternal class <class name>" with "@Serializable\ninternal class <class name>"
- "@JsonObject\nclass <class name>" with "@Serializable\nclass <class name>"
- "@JsonField(name = ["<property name>"])" with "@SerialName("<property name>")"

Everything else in the file should be copied without any changes.

Please migrate the following file:
$contents

We just want the file. Don't comment on the result.

and even then, small details did matter a lot.

For example, at the beginning of the prompt I refer to a file but later in the text I was saying Please migrate the following class. That alone was resulting in various weird migrations where a class was either missing completely or had only half of its initial code. Same results when I wasn’t using \n after the annotations.

The code

import gr.le0nidas.kotlin.ollama.OllamaClient
import gr.le0nidas.kotlin.ollama.request.GenerateRequest
import gr.le0nidas.kotlin.ollama.request.parameter.Model
import gr.le0nidas.kotlin.ollama.request.parameter.Prompt
import java.io.File
fun main(args: Array<String>) {
val ollamaClient = OllamaClient()
val requestBuilder = GenerateRequest.Builder(Model("llama3.2"))
val files = getAllFilePaths(args[0])
files.forEach { file ->
println("- Migrating file $file…")
val content = getFileContents(file)
val request = requestBuilder.build(prompt = createPrompt(content))
val response = ollamaClient.generate(request)
response
.onSuccess { saveFileContents(file, it.value) }
.onFailure { println(it.message) }
}
}
fun createPrompt(contents: String) = Prompt(
"""
We have a file written in Kotlin and we need to migrate it from LoganSquare to KotlinX Serialization.
To do that we have to replace:
– "import com.bluelinelabs.logansquare.annotation.JsonField" with "import kotlinx.serialization.SerialName"
– "import com.bluelinelabs.logansquare.annotation.JsonObject" with "import kotlinx.serialization.Serializable"
– "@JsonObject\ninternal class <class name>" with "@Serializable\ninternal class <class name>"
– "@JsonObject\nclass <class name>" with "@Serializable\nclass <class name>"
– "@JsonField(name = ["<property name>"])" with "@SerialName("<property name>")"
Everything else in the file should be copied without any changes.
Please migrate the following file:
$contents
We just want the file. Don't comment on the result.
""".trimIndent()
)
fun getAllFilePaths(directoryPath: String): List<String> {
val directory = File(directoryPath)
if (!directory.exists() || !directory.isDirectory) {
println("Directory does not exist or is not a directory")
return emptyList()
}
return directory.listFiles()
?.filter { it.isFile && it.name.endsWith(".kt") }
?.map { it.path }
?.toList()
?: emptyList()
}
fun getFileContents(filePath: String): String {
return try {
File(filePath).readText()
} catch (e: Exception) {
println("Error reading file: ${e.message}")
""
}
}
fun saveFileContents(filePath: String, contents: String) {
val file = File(filePath)
file.writeText(contents)
}

Conclusion

Was I faster than choice number two? Didn’t try this choice but I guess no. Too many things to learn, figure out and write.
Do I regret it? No! I now have a new tool in my belt and I’m pretty sure it will pay off, time wise, in the future.

ollama-kotlin-playground

One more thing that came out of this endeavour is ollama-kotlin-playground. A very simple library that does only one thing: generate a completion without even supporting all possible parameters. It is my way of not copying code from one tool/experiment to another.

TIL: @NullAndEmptySource in JUnit5

This is a clear case of RTFM!

I wanted to make sure that a function will return null when given a null or empty string and what I ended up doing was something like this:

internal class CreateNameTest {
@ParameterizedTest
@ValueSource(strings = ["null", "", " "])
fun `there is no creation when the provided value is null or empty or blank`(providedValue: String?) {
val value: String? = if (providedValue == "null")
null else
providedValue
assertThat(createName(value), absent())
}
}

@ValueSource does not accept null values so I passed it indirectly!

I didn’t like it so after, finally, reading the JUnit5 documentation I learned that the library had me covered from the beginning by providing three annotations exactly for this use case:

@NullSource, @EmptySource and @NullOrEmptySource are meant to be used whenever we need to check the behavior of our code when given null or empty inputs.

So the test changes to:

internal class CreateNameTest {
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = [" "])
fun `there is no creation when the provided value is null or empty or blank`(providedValue: String?) {
assertThat(createName(providedValue), absent())
}
}

PS: for the curious, the absent() is part of the hamkrest library by Nat Pryce (yes, that Nat Pryce)

TIL: vararg in Kotlin is never nullable

Today I came across a piece of code that looked like this:

fun printAll(vararg names: String?) {
  names.forEach { name -> println(name) }
}

and noticed that the IDE did not complain about using names without checking if it is null names?.forEach { ... }!

After decompiling Kotlin’s bytecode I saw that no matter what type I use (String? or String) the java code was the same:

public static final void printAll(@NotNull String... languages) {
  //...
}

Does the Kotlin compiler ignore the nullable type completely? Turns out that yes!

And the reason is quite clear and straightforward but it hadn’t registered in my mind until now:

Note that vararg parameters are, as a rule, never nullable, because in Java there is no good way to distinguish between passing null as the entire vararg array versus passing null as a single element of a non-null vararg array.

kotlin’s forums

What if we pass a null value?

As a matter of fact if you pass a null value:

printAll(null)

the compiler makes sure that the java code will be called with a null value casted to the appropriate type:

printAll((String)null);

which ends up in an array of strings that has one element!