feat: added core logic

This commit is contained in:
darwincereska
2025-12-15 16:52:04 -05:00
parent fa355acfa7
commit e3b5a54fe2
11 changed files with 501 additions and 10 deletions

View File

@@ -9,6 +9,7 @@ repositories {
dependencies {
implementation("org.kargs:kargs:1.0.8")
implementation("com.varabyte.kotter:kotter-jvm:1.2.1")
}
application {

View File

@@ -7,7 +7,7 @@ fun main(args: Array<String>) {
val parser = Parser("pledge")
parser.subcommands(
CheckCommand()
LintCommand()
)
try {

View File

@@ -1,9 +0,0 @@
package io.pledge.commands
import org.kargs.*
class CheckCommand : Subcommand("check", "Checks if commit title is valid") {
override fun execute() {
}
}

View File

@@ -0,0 +1,11 @@
package io.pledge.commands
import org.kargs.*
class LintCommand : Subcommand("lint", "Checks if commit title is valid") {
val message by Option(ArgType.String, "message", "m", description = "Message to lint", required = true)
override fun execute() {
}
}

View File

@@ -0,0 +1,82 @@
package io.pledge.core
import io.pledge.core.*
class App {
private var type: CommitType = CommitType.EMPTY
private var scope: String? = null
private var description: String = ""
private val body: MutableList<String> = mutableListOf()
private val changes: MutableList<Change> = mutableListOf()
/* Sets the type of the commit */
fun setType(type: CommitType): Result<Unit> {
if (type == CommitType.EMPTY) return Result.failure(Exception("Commit type cannot be EMPTY"))
this.type = type
return Result.success(Unit)
}
/* Sets the scope of the commit */
fun setScope(value: String): Result<Unit> {
val validScopePattern = Regex("^[a-z0-9-]+$") // lowercase/hyphens/numbers only
if (!validScopePattern.matches(value)) return Result.failure(Exception("Scope can only be lowercase/hyphens/numbers"))
if (value.length > 20) return Result.failure(Exception("Scope cannot be longer than 20 characters"))
this.scope = value
return Result.success(Unit)
}
/* Sets the description of the commit */
fun setDescription(value: String): Result<Unit> {
if (value.isBlank()) return Result.failure(Exception("Description cannot be empty"))
if (!value.first().isLetter() || !value.first().isLowerCase()) return Result.failure(Exception("Description must start with lowercase letter"))
if (value.endsWith(".")) return Result.failure(Exception("Description cannot end with a period"))
if (value.length > 50) return Result.failure(Exception("Description cannot be longer than 50 characters"))
this.description = value
return Result.success(Unit)
}
/* Sets the body of the commit */
fun setBody(body: List<String>): Result<Unit> {
this.body.clear()
this.body.addAll(body)
return Result.success(Unit)
}
/* Add a single body line */
fun addBodyLine(line: String): Result<Unit> {
if (line.length > 72) return Result.failure(Exception("Body line cannot be longer than 72 characters"))
this.body.add(line)
return Result.success(Unit)
}
/* Sets the changes in the commit */
fun setChanges(changes: List<Change>): Result<Unit> {
this.changes.clear()
this.changes.addAll(changes)
return Result.success(Unit)
}
/* Add a single change */
fun addChange(change: Change): Result<Unit> {
this.changes.add(change)
return Result.success(Unit)
}
/* Returns current state for preview */
fun getCurrentState(): Commit = Commit(type, scope, description, body.toList(), changes.toList())
/* Retuns immutable commit */
fun generateCommit(): Commit = Commit(type, scope, description, body.toList(), changes.toList())
/* Reset the app state */
fun reset() {
type = CommitType.EMPTY
scope = null
description = ""
body.clear()
changes.clear()
}
}

View File

@@ -0,0 +1,12 @@
package io.pledge.core
import io.pledge.core.CommitType
import io.pledge.core.Change
data class Commit(
val type: CommitType,
val scope: String? = null,
val description: String,
val body: List<String> = emptyList(),
val changes: List<Change> = emptyList(),
)

View File

@@ -0,0 +1,15 @@
package io.pledge.core
enum class CommitType(val value: String, val desc: String) {
FEAT("feat", "A new feature"),
FIX("fix", "A bug fix"),
REFACTOR("refactor", "Code changes that neither fixes a bug nor adds a feature"),
DOCS("docs", "Documentation only changes"),
STYLE("style", "Changes that do not affect the meaning of the code"),
TEST("test", "Add missing tests or correcting existing tests"),
CHORE("chore", "Changes to the build process or auxilliary tools"),
REVERT("revert", "Reverts to a previous commit"),
MERGE("merge", "Merging branches"),
DEPLOY("deploy", "Deployment related changes"),
EMPTY("", ""),
}

View File

@@ -0,0 +1,145 @@
package io.pledge.core
import io.pledge.core.Commit
import io.pledge.core.CommitType
import io.pledge.core.ChangeType
object Converter {
/* Converts Commit object into a git commit */
fun convertToGitCommit(commit: Commit): String {
val typeString = commit.type.toString().lowercase()
// Build the commit header: type(scope): description
val header = buildString {
append(typeString)
commit.scope?.let { scope -> append("($scope)") }
append(": ")
append(commit.description)
}
// Build the full commit message
return buildString {
append(header)
// Add body if present
if (commit.body.isNotEmpty()) {
append("\n\n")
append(commit.body.joinToString("\n"))
}
// Add file changes if present
if (commit.changes.isNotEmpty()) {
append("\n\n")
commit.changes.forEach { change ->
val changeSymbol = when (change.change) {
ChangeType.ADDED -> "+"
ChangeType.MODIFIED -> "~"
ChangeType.DELETED -> "-"
}
append("$changeSymbol ${change.file}")
if (change != commit.changes.last()) {
append("\n")
}
}
}
}
}
/* Converts git commit into Commit object */
fun convertFromGitCommit(commitMessage: String): Commit {
val lines = commitMessage.split("\n")
if (lines.isEmpty()) return Commit(CommitType.EMPTY, null, "", emptyList(), emptyList())
// Parse the header line: type(scope): description
val headerLine = lines[0]
val (type, scope, description) = parseHeader(headerLine)
// Find where the body starts and ends, and where the file changes start
var bodyStartIndex = -1
var bodyEndIndex = -1
var changesStartIndex = -1
var changesEndIndex = -1
for (i in 1 until lines.size) {
val line = lines[i]
when {
line.isEmpty() && bodyStartIndex == -1 -> {
// First empty line after header - body might start next
if (i + 1 < lines.size && lines[i + 1].isNotEmpty() && !isFileChangeLine(lines[i + 1])) {
bodyStartIndex = i + 1
}
}
line.isEmpty() && bodyStartIndex != -1 && bodyEndIndex == -1 -> {
// Empty line after body content - body ends here
bodyEndIndex = i - 1
}
isFileChangeLine(line) && changesStartIndex == -1 -> {
// First file change line
changesStartIndex = i
if (bodyStartIndex != -1 && bodyEndIndex == -1) {
bodyEndIndex = i - 2 // Account for empty line before changes
}
break
}
}
}
// Extract body
val body = if (bodyStartIndex != -1 && bodyEndIndex != -1 && bodyEndIndex >= bodyStartIndex) {
lines.subList(bodyStartIndex, bodyEndIndex + 1).filter { it.isNotEmpty() }
} else if (bodyStartIndex != -1 && changesStartIndex == -1) {
// Body goes to end of commit if no file changes
lines.subList(bodyStartIndex, lines.size).filter { it.isNotEmpty() }
} else {
emptyList()
}
// Extract file changes
val changes = if (changesStartIndex != -1) {
lines.subList(changesStartIndex, lines.size)
.filter { it.isNotEmpty() && isFileChangeLine(it) }
.mapNotNull { parseFileChange(it) }
} else {
emptyList()
}
return Commit(type, scope, description, body, changes)
}
private fun parseHeader(headerLine: String): Triple<CommitType, String?, String> {
// Regex to match: type(scope): description or type: description
val regex = Regex("""^(\w+)(?:\(([^)]+)\))?\s*:\s*(.+)$""")
val matchResult = regex.find(headerLine)
return if (matchResult != null) {
val typeString = matchResult.groupValues[1].uppercase()
val scope = matchResult.groupValues[2].takeIf { it.isNotEmpty() }
val description = matchResult.groupValues[3]
val commitType = try {
CommitType.valueOf(typeString)
} catch (e: IllegalArgumentException) {
CommitType.EMPTY // Fallback for unknown types
}
Triple(commitType, scope, description)
} else {
// Fallback if header doesn't match expected format
Triple(CommitType.EMPTY, null, headerLine)
}
}
private fun isFileChangeLine(line: String): Boolean = line.startsWith("+ ") || line.startsWith("~ ") || line.startsWith("- ")
private fun parseFileChange(line: String): Change? {
return when {
line.startsWith("+ ") -> Change(line.substring(2), ChangeType.ADDED)
line.startsWith("~ ") -> Change(line.substring(2), ChangeType.MODIFIED)
line.startsWith("- ") -> Change(line.substring(2), ChangeType.DELETED)
else -> null
}
}
}

View File

@@ -0,0 +1,53 @@
package io.pledge.core
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
fun getTrackedFiles(repoPath: String): List<Change> {
val changes = mutableListOf<Change>()
// Get staged changes
val stagedProcess = ProcessBuilder("git", "status", "--porcelain")
.directory(File(repoPath))
.start()
stagedProcess.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (line.length >= 3) {
val gitStatus = line.take(2)
val filePath = line.substring(3)
val changeType = mapGitStatusToChangeType(gitStatus)
changeType?.let {
changes.add(Change(filePath, it))
}
}
}
}
return changes
}
private fun mapGitStatusToChangeType(gitStatus: String): ChangeType? {
return when {
"A" in gitStatus -> ChangeType.ADDED
"D" in gitStatus -> ChangeType.DELETED
"M" in gitStatus -> ChangeType.MODIFIED
"R" in gitStatus -> ChangeType.MODIFIED // Renamed files are considered changed
"C" in gitStatus -> ChangeType.MODIFIED // Copied files are considered changed
"U" in gitStatus -> ChangeType.MODIFIED // Unmerged files are considered modified
else -> null // Ignore untracked (??) and other statuses
}
}
data class Change(
val file: String,
val change: ChangeType
)
enum class ChangeType {
ADDED,
DELETED,
MODIFIED,
}

View File

@@ -0,0 +1,170 @@
package io.pledge.core
import io.pledge.core.*
object Validator {
fun validateCommit(commit: Commit): ValidationResult {
val errors = mutableListOf<String>()
// Validate commit type
if (commit.type == CommitType.EMPTY) {
errors.add("Commit type cannot be EMPTY")
}
// Validate description
validateDescription(commit.description)?.let { error ->
errors.add(error)
}
// Validate scope if present
commit.scope?.let { scope ->
validateScope(scope)?.let { error ->
errors.add(error)
}
}
// Validate body lines
commit.body.forEachIndexed { index, line ->
validateBodyLine(line, index)?.let { error ->
errors.add(error)
}
}
// Validate changes
commit.changes.forEach { change ->
validateChange(change)?.let { error ->
errors.add(error)
}
}
return ValidationResult(errors.isEmpty(), errors)
}
private fun validateDescription(description: String): String? {
return when {
description.isBlank() -> "Description cannot be empty"
description.length > 72 -> "Description should not exceed 72 characters (current: ${description.length})"
description.first().isUpperCase() -> "Description should start with lowercase letter"
description.endsWith(".") -> "Description should not end with a period"
else -> null
}
}
private fun validateScope(scope: String): String? {
return when {
scope.isBlank() -> "Scope cannot be empty if provided"
scope.length > 20 -> "Scope should not exceed 20 characters (current: ${scope.length})"
!scope.matches(Regex("^[a-z0-9-]+$")) -> "Scope should only contain lowercase letters, numbers, and hyphens"
else -> null
}
}
private fun validateBodyLine(line: String, index: Int): String? {
return when {
line.length > 72 -> "Body line ${index + 1} should not exceed 72 characters (current: ${line.length})"
else -> null
}
}
private fun validateChange(change: Change): String? {
return when {
change.file.isBlank() -> "File path cannot be empty"
change.file.contains("..") -> "File path should not contain '..' (security risk)"
change.file.startsWith("/") -> "File path should be relative, not absolute"
else -> null
}
}
// Additional validation methods for specific scenarios
fun validateCommitType(type: String): ValidationResult {
val errors = mutableListOf<String>()
if (type.isBlank()) {
errors.add("Commit type cannot be empty")
} else {
try {
val commitType = CommitType.valueOf(type.uppercase())
if (commitType == CommitType.EMPTY) {
errors.add("Commit type cannot be EMPTY")
}
} catch (e: IllegalArgumentException) {
errors.add("Invalid commit type: '$type'. Valid types are: ${CommitType.values().filter { it != CommitType.EMPTY }.joinToString(", ")}")
}
}
return ValidationResult(errors.isEmpty(), errors)
}
fun validateGitCommitMessage(commitMessage: String): ValidationResult {
val errors = mutableListOf<String>()
if (commitMessage.isBlank()) {
errors.add("Commit message cannot be empty")
return ValidationResult(false, errors)
}
val lines = commitMessage.split("\n")
val headerLine = lines[0]
// Validate header format
val headerRegex = Regex("""^(\w+)(?:\(([^)]+)\))?\s*:\s*(.+)$""")
if (!headerRegex.matches(headerLine)) {
errors.add("Header line must follow format: 'type(scope): description' or 'type: description'")
} else {
val matchResult = headerRegex.find(headerLine)!!
val type = matchResult.groupValues[1]
val scope = matchResult.groupValues[2].takeIf { it.isNotEmpty() }
val description = matchResult.groupValues[3]
// Validate each component
validateCommitType(type).errors.forEach { errors.add(it) }
scope?.let { validateScope(it)?.let { error -> errors.add(error) } }
validateDescription(description)?.let { error -> errors.add(error) }
}
// Validate total length
if (commitMessage.length > 2000) {
errors.add("Commit message is too long (${commitMessage.length} characters). Consider keeping it under 2000 characters.")
}
return ValidationResult(errors.isEmpty(), errors)
}
fun validateFileChanges(changes: List<Change>): ValidationResult {
val errors = mutableListOf<String>()
if (changes.isEmpty()) {
errors.add("At least one file change is required")
return ValidationResult(false, errors)
}
// Check for duplicate files
val duplicateFiles = changes.groupBy { it.file }
.filter { it.value.size > 1 }
.keys
if (duplicateFiles.isNotEmpty()) {
errors.add("Duplicate files found: ${duplicateFiles.joinToString(", ")}")
}
// Validate each change
changes.forEach { change ->
validateChange(change)?.let { error ->
errors.add(error)
}
}
return ValidationResult(errors.isEmpty(), errors)
}
}
data class ValidationResult(
val isValid: Boolean,
val errors: List<String> = emptyList()
) {
fun hasErrors(): Boolean = errors.isNotEmpty()
fun getErrorMessage(): String = errors.joinToString("; ")
}

View File

@@ -0,0 +1,11 @@
package io.pledge.ui
import com.varabyte.kotter.foundation.*
import com.varabyte.kotter.foundation.input.*
import com.varabyte.kotter.foundation.text.*
import com.varabyte.kotter.runtime.concurrent.*
import io.pledge.core.*
class CommitBuilder {
}