Arcology Engine

The Org Document Editor

Contents

The editor package is the single interface every ViewModel uses to modify org files. It consolidates duplicated editing logic from capture, quiz, search, and settings ViewModels into a shared, testable component.

All operations follow the same pattern: read file → find heading → modify → write → re-index.

The editor depends on the org-mode parser (parser.org), the database repository (models.org), and the file system / indexer (indexer.org).

Design Decisions

Why a separate editor package?

The same transformation operations (set property, change TODO state, refile heading) were duplicated across capture, quiz, and settings ViewModels. Extracting them into a single service means:

  • One test suite covers all editing operations.

  • ViewModels only call high-level operations like setProperty(nodeId, "GEO_COORDS", "37.77, -122.41").

  • The filesystem interface is abstracted away — the editor doesn't care if it's SAF on Android or NIO on JVM.

Text surgery vs. AST rewrite

The editor performs precise string operations on raw file content rather than full AST round-trips. This is a deliberate trade-off:

  • Full AST → text would require re-serializing the entire document, losing comments, blank lines, and formatting.

  • Raw string edits preserve the user's exact formatting (including whitespace quirks).

  • The cost is that operations are brittle — heading position, property drawer boundaries, and tag placement must be computed correctly.

HeadingTextUtils and PropertyDrawerUtils exist because OrgDocumentEditor grew too large. The heading-level concerns (body replacement, refile, level adjustment) split naturally from the property-level concerns (drawer CRUD).

Regexp soup — acknowledged technical debt

Several operations use regex for heading parsing and tag manipulation. These are marked with inline TODO comments. Once the editor is fully documented, we should replace the regex-based heading parser with proper org-mode-kmp token positions.

The Write Interface — OrgDocumentEditor

OrgDocumentEditor is the public API. It takes a FileSystemInterface, a RoamRepository, and a FileIndexingService. Every editing operation is a suspend fun that returns an EditResult (Success with updated content, or Error with message).

kotlin#+name: ode-preamble
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

private const val TAG = "OrgDocumentEditor"

/**
 * Result of a document editing operation.
 */
sealed class EditResult {
    data class Success(val updatedContent: String) : EditResult()
    data class Error(val message: String) : EditResult()
}

/**
 * Multiplatform service for editing org-mode documents.
 * Consolidates duplicated editing logic from ViewModels into a shared, testable component.
 *
 * This service handles:
 * - Property operations (set/remove)
 * - Body editing
 * - Heading refile operations
 *
 * All operations follow the pattern: read file -> find heading -> modify -> write -> re-index
 */
class OrgDocumentEditor(
    private val fileSystem: FileSystemInterface,
    private val repository: RoamRepository,
    private val indexingService: FileIndexingService,
    private val defaultRepeatToState: String = "TODO"
) {
kotlin#+name: ode-todo-state

    /**
     * Update the TODO state of a heading.
     *
     * In addition to rewriting the heading keyword, this appends a
     * =:LOGBOOK:= state-change entry recording the transition — mirroring
     * org-mode's =org-log-into-drawer= behavior.
     *
     * @param nodeId The node ID to modify
     * @param todoState The new TODO state (e.g., "TODO", "DONE", "CANCELLED") or null to remove
     * @return EditResult indicating success or failure
     */
    suspend fun updateTodoState(nodeId: String, todoState: String?): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            if (nodeLevel == 0) {
                // Level-0 nodes can't have planning info / repeaters; do simple rewrite
                return@modifyAndPersist updateTodoStateInHeading(content, headingPosition, todoState)
            }
            val currentFrom = readCurrentTodoState(content, headingPosition)
            val doneStates = setOf("DONE", "CANCELLED", "ARCHIVED")

            // If entering a done state, check for repeater reschedule
            if (todoState != null && todoState in doneStates && todoState != currentFrom) {
                val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
                val reschedule = PlanningInfoUtils.computeReschedule(content, headingPosition, today, defaultRepeatToState)
                if (reschedule != null) {
                    // Reschedule: advance the planning timestamp, flip TODO to the reschedule target
                    var modified = updateTodoStateInHeading(reschedule.newContent, headingPosition, reschedule.newTodoState)
                    // Log the transition matching Emacs org-log-into-drawer:
                    //   - State "DONE" from "NEXT" [timestamp]
                    // The user's action (todoState, e.g. DONE) is the toState;
                    // the previous active state (currentFrom, e.g. NEXT) is the fromState.
                    modified = appendLogbookEntry(
                        modified,
                        headingPosition,
                        fromState = currentFrom ?: "nil",
                        toState = todoState
                    )
                    return@modifyAndPersist modified
                }
            }

            // No repeater, or not entering done state: simple rewrite + optional log
            var modified = updateTodoStateInHeading(content, headingPosition, todoState)
            if (todoState != null && todoState != currentFrom) {
                modified = appendLogbookEntry(
                    modified,
                    headingPosition,
                    fromState = currentFrom ?: "nil",
                    toState = todoState
                )
            }
            modified
        }
    }

    /**
     * Read the current TODO keyword from a heading line, or null if none.
     */
    private fun readCurrentTodoState(content: String, headingPosition: Int): String? {
        val headingLineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
        val headingLine = content.substring(headingPosition, headingLineEnd)
        val starMatch = Regex("^(\\*+)\\s+").find(headingLine) ?: return null
        val afterStars = headingLine.substring(starMatch.range.last + 1)
        val todoMatch = Regex("^([A-Z]+)\\s+").find(afterStars)
        return if (todoMatch != null && isTodoKeyword(todoMatch.groupValues[1])) {
            todoMatch.groupValues[1]
        } else {
            null
        }
    }

    /**
     * Update the TODO state in a heading line.
     */
    private fun updateTodoStateInHeading(content: String, headingPosition: Int, todoState: String?): String {
        val headingLineEnd = content.indexOf('\n', headingPosition).let { if (it == -1) content.length else it }
        val headingLine = content.substring(headingPosition, headingLineEnd)
        
        // Parse current heading: * [*] [TODO_STATE] TITLE [:TAGS:]
        val starMatch = Regex("^(\\*+)\\s+").find(headingLine) ?: return content
        val stars = starMatch.groupValues[1]
        val afterStars = headingLine.substring(starMatch.range.last + 1)
        
        // Check if there's a TODO state already
        val todoMatch = Regex("^([A-Z]+)\\s+").find(afterStars)
        val hasTodo = todoMatch != null && isTodoKeyword(todoMatch.groupValues[1])
        
        val titleAndTags = if (hasTodo) {
            afterStars.substring(todoMatch.range.last + 1)
        } else {
            afterStars
        }
        
        // Build new heading line
        val newHeadingLine = buildString {
            append(stars)
            append(' ')
            if (todoState != null) {
                append(todoState)
                append(' ')
            }
            append(titleAndTags)
        }
        
        return content.substring(0, headingPosition) + newHeadingLine + content.substring(headingLineEnd)
    }
    
    /**
     * Check if a string is a valid TODO keyword.
     */
    private fun isTodoKeyword(text: String): Boolean {
        return text in setOf("TODO", "NEXT", "INPROGRESS", "DONE", "CANCELLED", "ARCHIVED")
    }
kotlin#+name: ode-heading-finder

    /**
     * Find the character position of a heading in file content by its ID property.
     *
     * @param content The file content to search
     * @param nodeId The node ID to find
     * @param nodeLevel The level of the node (0 for file-level)
     * @return Character offset of the heading start, or null if not found
     */
    fun findHeadingPosition(content: String, nodeId: String, nodeLevel: Int): Int? {
        // For level-0 nodes (file-level), position is always 0
        if (nodeLevel == 0) return 0

        // Search for the ID property
        val idPattern = Regex(":ID:\\s*${Regex.escape(nodeId)}\\s*$", RegexOption.MULTILINE)
        val idMatch = idPattern.find(content) ?: return null

        // Walk backwards to find the heading line
        val beforeId = content.substring(0, idMatch.range.first)
        val headingPattern = Regex("^\\*+ .*$", RegexOption.MULTILINE)
        val lastHeadingMatch = headingPattern.findAll(beforeId).lastOrNull()

        return lastHeadingMatch?.range?.first ?: 0
    }

    /**
     * Find the boundaries (start and end positions) of a heading in file content.
     *
     * @param content The file content to search
     * @param nodeId The node ID to find
     * @param nodeLevel The level of the node
     * @return Pair of (headingStart, nextHeadingStart or null for end of file)
     */
    fun findHeadingBoundaries(content: String, nodeId: String, nodeLevel: Int): Pair<Int, Int?>? {
        val headingPosition = findHeadingPosition(content, nodeId, nodeLevel) ?: return null

        // For level-0 nodes, the content spans the entire file
        if (nodeLevel == 0) {
            return Pair(0, null)
        }

        // Find the actual heading level at this position
        val line = content.substring(headingPosition).takeWhile { it != '\n' }
        val actualLevel = line.takeWhile { it == '*' }.length

        // Use findHeadingEndPosition to get position after all children (not just siblings)
        val nextHeadingPos = HeadingTextUtils.findHeadingEndPosition(content, headingPosition, actualLevel)
        return Pair(headingPosition, nextHeadingPos)
    }
kotlin#+name: ode-property-drawer

    /**
     * Set a property value in a node's property drawer.
     * Creates a property drawer if one doesn't exist.
     * Handles both heading-level (level > 0) and file-level (level 0) nodes.
     *
     * @param nodeId The node ID to modify
     * @param key Property key (e.g., "GEO_COORDS", "PINNED")
     * @param value Property value
     * @return EditResult indicating success or failure
     */
    suspend fun setProperty(nodeId: String, key: String, value: String): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            if (nodeLevel == 0) {
                setFileLevelProperty(content, key, value)
            } else {
                PropertyDrawerUtils.setProperty(content, headingPosition, key, value)
            }
        }
    }

    /**
     * Set a property on a heading identified by its raw character position
     * (rather than by node ID). Used when a heading doesn't have an ID yet.
     *
     * @param fileUri The file containing the heading
     * @param headingPosition Character offset of the heading start
     * @param key Property key
     * @param value Property value
     * @return EditResult indicating success or failure
     */
    suspend fun setPropertyByPosition(fileUri: String, headingPosition: Int, key: String, value: String): EditResult {
        val content = try {
            fileSystem.readFile(fileUri)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        val newContent = PropertyDrawerUtils.setProperty(content, headingPosition, key, value)
            ?: return EditResult.Error("Failed to set property at position $headingPosition")

        return persistAndReindex(fileUri, newContent)
    }

    /**
     * Remove a property from a node's property drawer.
     * Handles both heading-level (level > 0) and file-level (level 0) nodes.
     *
     * @param nodeId The node ID to modify
     * @param key Property key to remove
     * @return EditResult indicating success or failure
     */
    suspend fun removeProperty(nodeId: String, key: String): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            if (nodeLevel == 0) {
                removeFileLevelProperty(content, key)
            } else {
                PropertyDrawerUtils.removeProperty(content, headingPosition, key)
            }
        }
    }

    /**
     * Set a property in the file-level property drawer (for level-0 nodes).
     * The file-level property drawer is at the very start of the file.
     */
    private fun setFileLevelProperty(content: String, key: String, value: String): String {
        val propertyLine = ":$key: $value"
        val trimmed = content.trimStart()

        if (trimmed.startsWith(":PROPERTIES:")) {
            // Find the existing property drawer
            val endIdx = content.indexOf(":END:")
            if (endIdx == -1) return content

            val drawerContent = content.substring(0, endIdx)

            // Check if this property already exists
            val propertyPattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
            return if (propertyPattern.containsMatchIn(drawerContent)) {
                // Update existing property
                val updatePattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
                content.replace(updatePattern) { match ->
                    // Only replace if it's within the drawer (before :END:)
                    if (match.range.first < endIdx) propertyLine else match.value
                }
            } else {
                // Insert new property before :END:
                content.substring(0, endIdx) + "$propertyLine\n" + content.substring(endIdx)
            }
        } else {
            // No property drawer - create one at the start of the file
            return ":PROPERTIES:\n$propertyLine\n:END:\n$content"
        }
    }

    /**
     * Remove a property from the file-level property drawer (for level-0 nodes).
     */
    private fun removeFileLevelProperty(content: String, key: String): String {
        val trimmed = content.trimStart()

        if (!trimmed.startsWith(":PROPERTIES:")) {
            // No property drawer, nothing to remove
            return content
        }

        val endIdx = content.indexOf(":END:")
        if (endIdx == -1) return content

        val drawerContent = content.substring(0, endIdx)
        val propertyPattern = Regex("^:${Regex.escape(key)}:.*\\n?", RegexOption.MULTILINE)

        if (!propertyPattern.containsMatchIn(drawerContent)) {
            // Property doesn't exist
            return content
        }

        // Count properties in the drawer (excluding :PROPERTIES: and :END: lines)
        val propLines = drawerContent.lines().filter { line ->
            val t = line.trim()
            t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
        }

        if (propLines.size <= 1) {
            // This is the only property (besides maybe :ID:) - but we shouldn't
            // remove the entire drawer if :ID: is still there.
            // Just remove this property line.
            val newDrawer = drawerContent.replace(propertyPattern, "")
            // Check if drawer would be empty (only :PROPERTIES: and whitespace left)
            val remainingProps = newDrawer.lines().filter { line ->
                val t = line.trim()
                t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
            }
            if (remainingProps.isEmpty()) {
                // Remove the entire drawer
                val fullEndIdx = content.indexOf(":END:") + ":END:".length
                val afterDrawer = content.substring(fullEndIdx).trimStart('\n')
                return afterDrawer
            }
        }

        // Remove just this property line from the drawer
        return content.replace(propertyPattern) { match ->
            if (match.range.first < endIdx) "" else match.value
        }
    }
kotlin#+name: ode-body-editing

    /**
     * Load the full heading content (including heading line, properties drawer, and body).
     * This is used when editing an existing node to populate the editor with the current content.
     *
     * @param nodeId The node ID to load
     * @return EditResult containing the full heading content on success, or error on failure
     */
    suspend fun loadHeadingContent(nodeId: String): EditResult {
        val node = repository.getNodeById(nodeId)
            ?: return EditResult.Error("Node not found: $nodeId")

        val content = try {
            fileSystem.readFile(node.file)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        val boundaries = findHeadingBoundaries(content, nodeId, node.level)
            ?: return EditResult.Error("Could not find heading position for node: $nodeId")

        val (headingPosition, nextHeadingPosition) = boundaries
        val fullContent = HeadingTextUtils.extractFullHeading(content, headingPosition, node.level)

        return EditResult.Success(fullContent)
    }

    /**
     * Replace the body text of a heading, preserving the heading line and properties drawer.
     *
     * @param nodeId The node ID to modify
     * @param newBody New body text to insert
     * @return EditResult indicating success or failure
     */
    suspend fun replaceHeadingBody(nodeId: String, newBody: String): EditResult {
        val node = repository.getNodeById(nodeId)
            ?: return EditResult.Error("Node not found: $nodeId")

        println("[$TAG] replaceHeadingBody: nodeId=$nodeId, node.file=${node.file}, node.level=${node.level}, newBody.length=${newBody.length}")

        val content = try {
            fileSystem.readFile(node.file)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        println("[$TAG] replaceHeadingBody: read content.length=${content.length}")

        val boundaries = findHeadingBoundaries(content, nodeId, node.level)
            ?: return EditResult.Error("Could not find heading position for node: $nodeId")

        val (headingPosition, nextHeadingPosition) = boundaries
        println("[$TAG] replaceHeadingBody: boundaries: headingPosition=$headingPosition, nextHeadingPosition=$nextHeadingPosition")
        println("[$TAG] replaceHeadingBody: original content[0..500]: ${content.substring(0, minOf(500, content.length))}")
        println("[$TAG] replaceHeadingBody: newBody.length=${newBody.length}, newBody[0..200]: ${newBody.substring(0, minOf(200, newBody.length))}")

        val newContent = HeadingTextUtils.replaceHeadingBody(
            content, headingPosition, nextHeadingPosition, newBody
        )
        println("[$TAG] replaceHeadingBody: newContent.length=${newContent.length}, newContent[0..500]: ${newContent.substring(0, minOf(500, newContent.length))}")

        return persistAndReindex(node.file, newContent)
    }

    /**
     * Extract the body text of a heading, excluding the heading line and properties drawer.
     *
     * @param content File content
     * @param headingPosition Character offset of the heading start
     * @param nextHeadingPosition Character offset of the next heading, or null for end of file
     * @return The body text
     */
    fun extractHeadingBody(content: String, headingPosition: Int, nextHeadingPosition: Int?): String {
        return HeadingTextUtils.extractHeadingBody(content, headingPosition, nextHeadingPosition)
    }
kotlin#+name: ode-refile

    /**
     * Refile a heading from its current location to under a target node.
     * Handles both same-file and cross-file refile operations.
     *
     * @param sourceNodeId The node ID to refile
     * @param targetNodeId The target node to refile under
     * @return EditResult indicating success or failure
     */
    suspend fun refileHeading(sourceNodeId: String, targetNodeId: String): EditResult {
        // Cannot refile to self
        if (sourceNodeId == targetNodeId) {
            return EditResult.Error("Cannot refile to the same node")
        }

        val sourceNode = repository.getNodeById(sourceNodeId)
            ?: return EditResult.Error("Source node not found: $sourceNodeId")

        // Cannot refile level-0 nodes (file-level)
        if (sourceNode.level == 0) {
            return EditResult.Error("Cannot refile file-level nodes")
        }

        val targetNode = repository.getNodeById(targetNodeId)
            ?: return EditResult.Error("Target node not found: $targetNodeId")

        val sourceContent = try {
            fileSystem.readFile(sourceNode.file)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read source file: ${e.message}")
        }

        val sourceBoundaries = findHeadingBoundaries(sourceContent, sourceNodeId, sourceNode.level)
            ?: return EditResult.Error("Could not find source heading position")

        val (headingPosition, _) = sourceBoundaries

        // Extract the heading content
        val headingContent = HeadingTextUtils.extractFullHeading(
            sourceContent, headingPosition, sourceNode.level
        )

        // Calculate target level: one deeper than target, or level 1 if target is file-level
        val newLevel = if (targetNode.level == 0) 1 else targetNode.level + 1

        // Adjust heading levels in the extracted content
        val adjustedContent = HeadingTextUtils.adjustHeadingLevels(headingContent, newLevel)

        val sourceUri = sourceNode.file
        val targetUri = targetNode.file
        val isSameFile = sourceUri == targetUri

        try {
            if (isSameFile) {
                // Same file refile
                val targetPosition = findHeadingPosition(sourceContent, targetNodeId, targetNode.level)
                    ?: return EditResult.Error("Could not find target node position in file")

                // Remove heading from source location first
                var modifiedContent = HeadingTextUtils.removeFullHeading(
                    sourceContent, headingPosition, sourceNode.level
                )

                // Adjust target position if it was after the removed heading
                val adjustedTargetPosition = if (targetPosition > headingPosition) {
                    val removedLength = sourceContent.length - modifiedContent.length
                    maxOf(0, targetPosition - removedLength)
                } else {
                    targetPosition
                }

                // Insert at new location
                modifiedContent = HeadingTextUtils.insertHeadingUnder(
                    modifiedContent,
                    adjustedTargetPosition,
                    targetNode.level,
                    adjustedContent
                )

                // Write modified file
                fileSystem.writeFile(sourceUri, modifiedContent)

                // Re-index (non-fatal)
                tryReindex(sourceUri)

                return EditResult.Success(modifiedContent)
            } else {
                // Different file refile
                val targetContent = try {
                    fileSystem.readFile(targetUri)
                } catch (e: Exception) {
                    return EditResult.Error("Failed to read target file: ${e.message}")
                }

                // Get target position in target file
                val targetPosition = findHeadingPosition(targetContent, targetNodeId, targetNode.level)
                    ?: 0 // Default to start if we can't find it (for file-level targets)

                // Remove heading from source file
                val modifiedSourceContent = HeadingTextUtils.removeFullHeading(
                    sourceContent, headingPosition, sourceNode.level
                )

                // Insert heading into target file
                val modifiedTargetContent = HeadingTextUtils.insertHeadingUnder(
                    targetContent,
                    targetPosition,
                    targetNode.level,
                    adjustedContent
                )

                // Write both files
                fileSystem.writeFile(sourceUri, modifiedSourceContent)
                fileSystem.writeFile(targetUri, modifiedTargetContent)

                // Re-index both files (non-fatal)
                tryReindex(sourceUri)
                tryReindex(targetUri)

                return EditResult.Success(modifiedTargetContent)
            }
        } catch (e: Exception) {
            return EditResult.Error("Failed to refile: ${e.message}")
        }
    }
kotlin#+name: ode-persistence

    /**
     * Internal helper that handles the common pattern of:
     * 1. Look up node
     * 2. Read file
     * 3. Find heading position
     * 4. Apply transformation
     * 5. Write file
     * 6. Re-index
     *
     * @param nodeId The node ID to modify
     * @param transform Function that transforms file content given (content, headingPosition, nodeLevel)
     * @return EditResult indicating success or failure
     */
    private suspend fun modifyAndPersist(
        nodeId: String,
        transform: (content: String, headingPosition: Int, nodeLevel: Int) -> String?
    ): EditResult {
        val node = repository.getNodeById(nodeId)
            ?: return EditResult.Error("Node not found: $nodeId")

        val content = try {
            fileSystem.readFile(node.file)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        val position = findHeadingPosition(content, nodeId, node.level)
            ?: return EditResult.Error("Could not find heading position for node: $nodeId")

        val newContent = transform(content, position, node.level)
            ?: return EditResult.Error("Transform failed")

        return persistAndReindex(node.file, newContent)
    }

    /**
     * Internal helper for position-based edits — skips the repository lookup.
     * Used by updateTodoStateByPosition / updatePlanningInfoByPosition for
     * headings that may not have an :ID: (and thus no OrgNode in the DB).
     *
     * @param file File path to edit
     * @param position Character offset of the heading start
     * @param transform Function that transforms file content given (content, headingPosition)
     * @return EditResult indicating success or failure
     */
    private suspend fun modifyAndPersistByPosition(
        file: String,
        position: Int,
        transform: (content: String, headingPosition: Int) -> String?
    ): EditResult {
        val content = try {
            fileSystem.readFile(file)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        val newContent = transform(content, position)
            ?: return EditResult.Error("Transform failed")

        return persistAndReindex(file, newContent)
    }

    /**
     * Write content to file and trigger re-indexing.
     */
    private suspend fun persistAndReindex(filePath: String, content: String): EditResult {
        println("[$TAG] persistAndReindex: filePath=$filePath, content.length=${content.length}")
        try {
            fileSystem.writeFile(filePath, content)
            println("[$TAG] persistAndReindex: file written successfully")
        } catch (e: Exception) {
            return EditResult.Error("Failed to write file: ${e.message}")
        }

        // Re-indexing is non-fatal - write succeeded even if re-index fails
        tryReindex(filePath)

        return EditResult.Success(content)
    }

    /**
     * Attempt to re-index a file. Failures are logged but don't affect the operation result.
     */
    private suspend fun tryReindex(filePath: String) {
        println("[$TAG] tryReindex: filePath=$filePath")
        try {
            withContext(Dispatchers.Default) {
                indexingService.indexFile(filePath)
            }
            println("[$TAG] tryReindex: indexFile completed")
        } catch (e: Exception) {
            println("[$TAG] tryReindex: exception during indexFile: ${e.message}")
        }
    }
kotlin#+name: ode-capture

    /**
     * Append a heading to the end of a file.
     * Used for capture operations where we're adding new entries to a daily file.
     *
     * @param filePath The file path to append to
     * @param headingContent The heading content to append (full heading with properties)
     * @return EditResult indicating success or failure
     */
    suspend fun appendHeading(filePath: String, headingContent: String): EditResult {
        val content = try {
            fileSystem.readFile(filePath)
        } catch (e: Exception) {
            return EditResult.Error("Failed to read file: ${e.message}")
        }

        // Append the heading content
        val trimmedContent = content.trimEnd('\n')
        val newContent = if (trimmedContent.isEmpty()) {
            headingContent
        } else {
            trimmedContent + "\n\n" + headingContent
        }

        return persistAndReindex(filePath, newContent)
    }
kotlin#+name: ode-review-data

    /**
     * Set REVIEW_DATA for a flashcard position in the node's REVIEW_DATA drawer.
     * Creates the REVIEW_DATA drawer if it doesn't exist.
     *
     * @param nodeId The flashcard node ID
     * @param positionName The position name (e.g., "front", "back", "0")
     * @param reviewData Review data to store
     * @return EditResult indicating success or failure
     */
    suspend fun setReviewData(nodeId: String, positionName: String, reviewData: computer.whatthefuck.arcology.flashcard.ReviewData): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            if (nodeLevel == 0) {
                // For file-level nodes, use file-level REVIEW_DATA drawer
                ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)
            } else {
                // For heading-level nodes, use REVIEW_DATA drawer after PROPERTIES
                ReviewDataDrawerUtils.setHeadingLevelReviewData(content, headingPosition, reviewData)
            }
        }
    }
kotlin#+name: ode-planning-info

    /**
     * Update a planning line (SCHEDULED, DEADLINE, or CLOSED) for a heading.
     * Pass null as [timestampString] to remove the planning line.
     *
     * @param nodeId The node ID to modify
     * @param kind Which planning keyword to update
     * @param timestampString Full org timestamp e.g. "<2026-08-15 Sat 14:00 +1w>" or null to remove
     * @return EditResult indicating success or failure
     */
    suspend fun updatePlanningInfo(
        nodeId: String,
        kind: PlanningKind,
        timestampString: String?
    ): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            if (nodeLevel == 0) null else PlanningInfoUtils.updatePlanningLine(content, headingPosition, kind, timestampString)
        }
    }
kotlin#+name: ode-position-based

    /**
     * Update the TODO state of a heading identified by file path and byte position.
     * Used for headings that may not have an :ID: (non-node headings).
     *
     * When entering a done state (DONE/CANCELLED/ARCHIVED) on a heading with a
     * repeater, this advances the planning timestamp and flips the TODO keyword
     * back to an active state (TODO or REPEAT_TO_STATE), mirroring org-mode's
     * org-auto-repeat-mode.
     *
     * @param file File path containing the heading
     * @param position Character offset of the heading start
     * @param todoState The new TODO state, or null to clear
     * @return EditResult indicating success or failure
     */
    suspend fun updateTodoStateByPosition(file: String, position: Int, todoState: String?): EditResult {
        return modifyAndPersistByPosition(file, position) { content, headingPosition ->
            // Determine level by counting stars at the heading position
            val line = content.substring(headingPosition).takeWhile { it != '\n' }
            val level = line.takeWhile { it == '*' }.length
            if (level == 0) {
                return@modifyAndPersistByPosition updateTodoStateInHeading(content, headingPosition, todoState)
            }
            val currentFrom = readCurrentTodoState(content, headingPosition)
            val doneStates = setOf("DONE", "CANCELLED", "ARCHIVED")

            // If entering a done state, check for repeater reschedule
            if (todoState != null && todoState in doneStates && todoState != currentFrom) {
                val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
                val reschedule = PlanningInfoUtils.computeReschedule(content, headingPosition, today, defaultRepeatToState)
                if (reschedule != null) {
                    var modified = updateTodoStateInHeading(reschedule.newContent, headingPosition, reschedule.newTodoState)
                    // Log the transition matching Emacs org-log-into-drawer:
                    //   - State "DONE" from "NEXT" [timestamp]
                    // The user's action (todoState, e.g. DONE) is the toState;
                    // the previous active state (currentFrom, e.g. NEXT) is the fromState.
                    modified = appendLogbookEntry(
                        modified,
                        headingPosition,
                        fromState = currentFrom ?: "nil",
                        toState = todoState
                    )
                    return@modifyAndPersistByPosition modified
                }
            }

            // No repeater, or not entering done state: simple rewrite + optional log
            var modified = updateTodoStateInHeading(content, headingPosition, todoState)
            if (todoState != null && todoState != currentFrom) {
                modified = appendLogbookEntry(
                    modified,
                    headingPosition,
                    fromState = currentFrom ?: "nil",
                    toState = todoState
                )
            }
            modified
        }
    }

    /**
     * Update a planning line for a heading identified by file path and byte position.
     * Used for headings that may not have an :ID: (non-node headings).
     *
     * @param file File path containing the heading
     * @param position Character offset of the heading start
     * @param kind Which planning keyword to update
     * @param timestampString Full org timestamp or null to remove
     * @return EditResult indicating success or failure
     */
    suspend fun updatePlanningInfoByPosition(
        file: String,
        position: Int,
        kind: PlanningKind,
        timestampString: String?
    ): EditResult {
        return modifyAndPersistByPosition(file, position) { content, headingPosition ->
            PlanningInfoUtils.updatePlanningLine(content, headingPosition, kind, timestampString)
        }
    }
kotlin#+name: ode-tags

    /**
     * Add a tag to a heading.
     * Tags are appended to the heading line after the title, separated by colons.
     * If the tag already exists, this is a no-op.
     *
     * @param nodeId The node ID to modify
     * @param tag The tag to add (e.g., "suspended")
     * @return EditResult indicating success or failure
     */
    suspend fun addTag(nodeId: String, tag: String): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            addTagToHeading(content, headingPosition, tag)
        }
    }

    /**
     * Add a tag to a heading in the file content.
     * Tags are appended to the heading line after the title, separated by colons.
     * If the tag already exists, returns the content unchanged.
     *
     * @param content File content
     * @param headingPosition Character offset of the heading start
     * @param tag The tag to add
     * @return Modified content with the tag added
     */
    private fun addTagToHeading(content: String, headingPosition: Int, tag: String): String {
        // Find the end of the heading line
        val headingEnd = findHeadingLineEnd(content, headingPosition)

        // Get the heading line
        val nextNewline = content.indexOf('\n', headingPosition)
        val headingLineEnd = if (nextNewline == -1) content.length else nextNewline
        val headingLine = content.substring(headingPosition, headingLineEnd)

        // Check if tag already exists
        val tagPattern = Regex(":${Regex.escape(tag)}:")
        if (tagPattern.containsMatchIn(headingLine)) {
            return content
        }

        // Find where to insert the tag (after title, before existing tags if any)
        // Heading format: ** Title :tag1:tag2:
        // We want to insert before the first :tag: or at the end before newline

        // Find the last non-whitespace character before the first tag or end of line
        val trimmedHeading = headingLine.trimEnd()
        val insertPos = content.lastIndexOf(trimmedHeading.lastOrNull() ?: ' ', headingLineEnd - 1) + 1

        // Insert the tag
        val newHeadingLine = headingLine.insertAt(trimmedHeading.length, ":${tag}:")
        return content.substring(0, headingPosition) + newHeadingLine + content.substring(headingLineEnd)
    }

    /**
     * Insert a string at a specific position in a string.
     */
    private fun String.insertAt(position: Int, other: String): String {
        return this.substring(0, position) + other + this.substring(position)
    }

    /**
     * Find the end position of a heading line.
     */
    private fun findHeadingLineEnd(content: String, headingPosition: Int): Int {
        val newlinePos = content.indexOf('\n', headingPosition)
        return if (newlinePos == -1) content.length else newlinePos
    }

    /**
     * Remove a tag from a heading.
     *
     * @param nodeId The node ID to modify
     * @param tag The tag to remove
     * @return EditResult indicating success or failure
     */
    suspend fun removeTag(nodeId: String, tag: String): EditResult {
        return modifyAndPersist(nodeId) { content, headingPosition, nodeLevel ->
            removeTagFromHeading(content, headingPosition, tag)
        }
    }

    /**
     * Remove a tag from a heading in the file content.
     *
     * @param content File content
     * @param headingPosition Character offset of the heading start
     * @param tag The tag to remove (without the colons)
     * @return Modified content with the tag removed
     */
    private fun removeTagFromHeading(content: String, headingPosition: Int, tag: String): String {
        // Find the heading line
        val nextNewline = content.indexOf('\n', headingPosition)
        val headingLineEnd = if (nextNewline == -1) content.length else nextNewline
        val headingLine = content.substring(headingPosition, headingLineEnd)

        // Check if tag exists
        val tagPattern = Regex(":${Regex.escape(tag)}:")
        if (!tagPattern.containsMatchIn(headingLine)) {
            return content
        }

        // Find and remove the tag
        val tagToFind = ":${tag}:"
        val tagIndex = headingLine.indexOf(tagToFind)

        if (tagIndex == -1) {
            return content
        }

        // Remove the tag
        val newHeadingLine = headingLine.removeRange(tagIndex, tagIndex + tagToFind.length)

        // Check if there are remaining tags (pattern :word: or word: when after tag removal)
        // After removing :tag1: from :tag1:tag2:, we get "tag2:" - still needs to be a tag
        val remainingTagPattern = Regex("(?::[a-zA-Z][a-zA-Z0-9_-]*:)|(?:[a-zA-Z][a-zA-Z0-9_-]*:)$")
        val hasRemainingTags = remainingTagPattern.containsMatchIn(newHeadingLine)

        val cleanedHeading = if (hasRemainingTags) {
            // Clean up the tag section
            // After removing a tag, we might have "tag2:" instead of ":tag2:"
            // Fix by ensuring remaining tags have proper format

            // First replace any "::" with ":"
            var cleaned = newHeadingLine.replace("::", ":")

            // Find where tags start - look for pattern like " :word" or at end of title
            val tagSectionMatch = Regex("\\s+(:?[a-zA-Z][a-zA-Z0-9_-]*:)+$").find(cleaned)
            if (tagSectionMatch != null) {
                val tagSection = tagSectionMatch.value
                val beforeTags = cleaned.substring(0, tagSectionMatch.range.first)

                // Ensure all tags start with : and end with :
                // Handle case like "tag2:" -> ":tag2:"
                val fixedTags = tagSection.trim()
                    .split(" ")
                    .filter { it.isNotEmpty() }
                    .joinToString(" ") { tagPart ->
                        if (tagPart.startsWith(":") && tagPart.endsWith(":")) {
                            tagPart
                        } else if (tagPart.startsWith(":")) {
                            tagPart + ":"
                        } else if (tagPart.endsWith(":")) {
                            ":$tagPart"
                        } else {
                            ":$tagPart:"
                        }
                    }

                val finalTags = if (fixedTags.isEmpty()) "" else " $fixedTags"
                beforeTags.trimEnd() + finalTags
            } else {
                cleaned
            }
        } else {
            // No remaining tags, just trim trailing colons and whitespace
            newHeadingLine.trimEnd(':', ' ')
        }

        return content.substring(0, headingPosition) + cleanedHeading + content.substring(headingLineEnd)
    }

    /**
     * Add a file-level tag (filetag) to an org file's =#+FILETAGS:= line.
     * If no =#+FILETAGS:= line exists, one is inserted after the =#+TITLE:= line
     * (or at the top of the file if no title). If the tag is already present,
     * this is a no-op.
     *
     * @param file File path to edit
     * @param tag The tag to add (e.g., "CLOSED")
     * @return EditResult indicating success or failure
     */
    suspend fun addFileTag(file: String, tag: String): EditResult {
        return modifyAndPersistByPosition(file, 0) { content, headingPosition ->
            addFileTagToContent(content, tag)
        }
    }

    internal fun addFileTagToContent(content: String, tag: String): String {
        // Match the entire #+FILETAGS: line, capturing everything after the keyword.
        // Filetags format: #+FILETAGS: :tag1:tag2:tag3:
        val filetagsPattern = Regex("(?im)^#\\+FILETAGS:\\s*(.*)$")
        val match = filetagsPattern.find(content)

        if (match != null) {
            val fullLine = match.value
            val existingTags = match.groupValues[1].trim() // e.g. ":tag1:tag2:" or ""
            // Check if tag already present
            val tagPattern = Regex(":${Regex.escape(tag)}:")
            if (tagPattern.containsMatchIn(existingTags)) {
                return content // already present
            }
            // Append the tag
            val newTags = if (existingTags.isEmpty()) {
                ":${tag}:"
            } else {
                // existingTags is ":tag1:tag2:", insert :tag: before trailing :
                existingTags.dropLast(1) + ":" + tag + ":"
            }
            val newLine = "#+FILETAGS: $newTags"
            return content.substring(0, match.range.first) + newLine + content.substring(match.range.last + 1)
        }

        // No #+FILETAGS: line — insert one after #+TITLE: (or at top)
        val titlePattern = Regex("(?im)^#\\+TITLE:.*$")
        val titleMatch = titlePattern.find(content)
        return if (titleMatch != null) {
            // Insert on a new line after the title line.
            // titleMatch.range.last is the last char of the title line (no newline).
            // We need to insert "\n#+FILETAGS: :tag:" after it.
            val insertPos = titleMatch.range.last + 1
            val insertLine = "\n#+FILETAGS: :${tag}:"
            content.substring(0, insertPos) + insertLine + content.substring(insertPos)
        } else {
            // No title — insert at the very top
            val insertLine = "#+FILETAGS: :${tag}:\n"
            content.substring(0, 0) + insertLine + content.substring(0)
        }
    }

    /**
     * Remove a file-level tag (filetag) from an org file's =#+FILETAGS:= line.
     * If the line becomes empty (just `#+FILETAGS:`), it is removed entirely.
     *
     * @param file File path to edit
     * @param tag The tag to remove
     * @return EditResult indicating success or failure
     */
    suspend fun removeFileTag(file: String, tag: String): EditResult {
        return modifyAndPersistByPosition(file, 0) { content, headingPosition ->
            removeFileTagFromContent(content, tag)
        }
    }

    internal fun removeFileTagFromContent(content: String, tag: String): String {
        val filetagsPattern = Regex("(?im)^#\\+FILETAGS:\\s*(.*)$")
        val match = filetagsPattern.find(content) ?: return content

        val existingTags = match.groupValues[1].trim()
        val tagPattern = Regex(":${Regex.escape(tag)}:")
        if (!tagPattern.containsMatchIn(existingTags)) {
            return content
        }

        val newTags = existingTags.replace(":${tag}:", ":")
        // If only ":" remains, remove the whole line
        return if (newTags == ":" || newTags.isBlank()) {
            // Remove the line and its trailing newline
            val removeEnd = if (match.range.last + 1 < content.length && content[match.range.last + 1] == '\n') {
                match.range.last + 2
            } else {
                match.range.last + 1
            }
            content.substring(0, match.range.first) + content.substring(removeEnd)
        } else {
            val newLine = "#+FILETAGS: $newTags"
            content.substring(0, match.range.first) + newLine + content.substring(match.range.last + 1)
        }
    }
kotlin#+name: ode-logbook

    @OptIn(ExperimentalTime::class)
    private fun appendLogbookEntry(
        content: String,
        headingPosition: Int,
        fromState: String,
        toState: String
    ): String {
        val now = Clock.System.now()
        val today = now.toLocalDateTime(TimeZone.currentSystemDefault())
        val weekdays = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
        val dayName = weekdays[today.dayOfWeek.ordinal]
        val dateStr = "${today.date} $dayName ${today.hour.toString().padStart(2, '0')}:${today.minute.toString().padStart(2, '0')}"
        val logbookEntry = "- State \"$toState\"  from \"$fromState\"  [$dateStr]"
        val headingEnd = findNextHeadingOrFileEnd(content, headingPosition)
        val section = content.substring(headingPosition, headingEnd)
        val logbookPattern = Regex(":LOGBOOK:")
        return if (logbookPattern.containsMatchIn(section)) {
            val logbookStart = section.indexOf(":LOGBOOK:")
            val afterStart = section.substring(logbookStart)
            val logbookLineEnd = afterStart.indexOf('\n').let { if (it == -1) afterStart.length else it }
            val insertPos = headingPosition + logbookStart + logbookLineEnd + 1
            content.substring(0, insertPos) + "$logbookEntry\n" + content.substring(insertPos)
        } else {
            val updatedSection = section.trimEnd('\n') + "\n\n:LOGBOOK:\n  $logbookEntry\n:END:\n"
            content.substring(0, headingPosition) + updatedSection + content.substring(headingEnd)
        }
    }

    private fun findNextHeadingOrFileEnd(content: String, headingPosition: Int): Int {
        val line = content.substring(headingPosition).takeWhile { it != '\n' }
        val level = line.takeWhile { it == '*' }.length
        if (level == 0) return content.length
        val pattern = Regex("^\\*{1,$level} ", RegexOption.MULTILINE)
        val nextMatch = pattern.find(content, headingPosition + 1)
        return nextMatch?.range?.first ?: content.length
    }
}
kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/OrgDocumentEditor.kt:noweb yes
<<ode-preamble>>
<<ode-todo-state>>
<<ode-heading-finder>>
<<ode-property-drawer>>
<<ode-body-editing>>
<<ode-refile>>
<<ode-persistence>>
<<ode-capture>>
<<ode-review-data>>
<<ode-planning-info>>
<<ode-position-based>>
<<ode-tags>>
<<ode-logbook>>

Heading Text Utilities

HeadingTextUtils is a set of low-level operations on raw heading text: body extraction, body replacement, heading removal, level adjustment, and insertion. It is used by both OrgDocumentEditor and NodeContentParser.

kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/HeadingTextUtils.kt
package computer.whatthefuck.arcology.editor

import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.core.ParsingResult
import xyz.lepisma.orgmode.core.zeroOrMore
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.lexer.Token
import xyz.lepisma.orgmode.parseChunk

/**
 * Utilities for extracting and replacing heading body text within org files.
 * Works with raw file content and heading positions (character offsets).
 */
object HeadingTextUtils {

    /**
     * Extract the body text of a heading, excluding the heading line itself
     * and the properties drawer.
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start (the `*` character)
     * @param nextHeadingPosition Character offset of the next sibling/parent heading, or null for end of file
     * @return The body text between properties drawer end and next heading
     */
    fun extractHeadingBody(
        fileContent: String,
        headingPosition: Int,
        nextHeadingPosition: Int?
    ): String {
        val endPos = nextHeadingPosition ?: fileContent.length
        val section = fileContent.substring(headingPosition, endPos)

        // Find end of heading line
        val headingLineEnd = section.indexOf('\n')
        if (headingLineEnd == -1) return ""

        val afterHeading = headingLineEnd + 1

        // Skip properties drawer if present
        val bodyStart = skipPropertiesDrawer(section, afterHeading)

        val body = section.substring(bodyStart).trimEnd('\n')

        // Strip REVIEW_DATA drawers from the body
        return stripReviewDataDrawers(body)
    }

    /**
     * Extract the body content from a level-0 (file-level) section.
     * Level-0 sections start with :PROPERTIES: drawer and #+title: directive,
     * followed by body content that may include sub-headings.
     *
     * @param section The section content (from headingPosition to endPos)
     * @param headingPosition The start position within the section (usually 0 for level-0)
     * @return Pair of (bodyEndPosition, childrenContent) where bodyEndPosition is the position
     *         to insert the new body, and childrenContent is any sub-headings to preserve
     */
    private fun extractLevel0Body(
        section: String,
        headingPosition: Int
    ): Pair<Int, String> {
        var pos = headingPosition

        // Skip :PROPERTIES: drawer if present
        if (section.substring(pos).trimStart().startsWith(":PROPERTIES:")) {
            val drawerEnd = section.indexOf(":END:", pos)
            if (drawerEnd == -1) return Pair(headingPosition, "")
            pos = drawerEnd + ":END:".length
            // Skip newline after :END:
            if (pos < section.length && section[pos] == '\n') {
                pos++
            }
        }

        // Skip #+title: directive
        val remaining = section.substring(pos).trimStart()
        if (remaining.startsWith("#+title:")) {
            val titleEnd = remaining.indexOf('\n')
            if (titleEnd != -1) {
                pos += titleEnd + 1
            } else {
                pos = section.length
            }
        }

        // Skip empty lines after title
        val afterTitle = section.substring(pos)
        var emptyLinesEnd = 0
        for (i in afterTitle.indices) {
            if (afterTitle[i] == '\n') {
                emptyLinesEnd = i + 1
            } else if (afterTitle[i] == ' ' || afterTitle[i] == '\t') {
                // continue through whitespace
            } else {
                break
            }
        }
        pos += emptyLinesEnd

        // Find sub-headings (lines starting with *) to preserve
        val remainingContent = section.substring(pos)
        val subHeadingPattern = Regex("^\\*+ ", RegexOption.MULTILINE)
        val subHeadingMatch = subHeadingPattern.find(remainingContent)

        val childrenContent = if (subHeadingMatch != null) {
            // Sub-heading found, preserve it and all following content
            val childStart = subHeadingMatch.range.first
            section.substring(pos + childStart)
        } else {
            // No sub-headings
            ""
        }

        return Pair(pos, childrenContent)
    }

    /**
     * Replace the body text of a heading, preserving the heading line and properties drawer.
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start
     * @param nextHeadingPosition Character offset of the next heading, or null for end of file
     * @param newBody New body text to insert
     * @return Modified file content
     */
    fun replaceHeadingBody(
        fileContent: String,
        headingPosition: Int,
        nextHeadingPosition: Int?,
        newBody: String
    ): String {
        val endPos = nextHeadingPosition ?: fileContent.length
        val section = fileContent.substring(headingPosition, endPos)

        // Check if this is a level-0 (file-level) node - content starts with :PROPERTIES: or #+title:
        val sectionTrimmed = section.trimStart()
        val isLevel0 = sectionTrimmed.startsWith(":PROPERTIES:") || sectionTrimmed.startsWith("#+title:")

        if (isLevel0) {
            // For level-0 nodes, replace the entire body after properties/title with new content
            // The user's newBody contains the complete desired content including any sub-headings
            // We don't preserve children because newBody is the complete replacement

            // Find the position to insert the body (after properties and title)
            val (bodyInsertPos, _) = extractLevel0Body(section, 0)
            val bodyEndPos = section.length

            // The body to replace is from bodyInsertPos to end of section
            val prefix = fileContent.substring(0, headingPosition)
            val headingAndProps = section.substring(0, bodyInsertPos)
            val suffix = fileContent.substring(endPos)

            // Normalize new body content
            val normalizedBody = when {
                newBody.isEmpty() -> ""
                else -> newBody.trimEnd() + "\n"
            }

            return prefix + headingAndProps + normalizedBody + suffix
        }

        // Original logic for regular headings
        // Find end of heading line
        val headingLineEnd = section.indexOf('\n')
        if (headingLineEnd == -1) {
            // Heading with no content - append body
            return fileContent.substring(0, headingPosition + section.length) +
                    "\n" + newBody + "\n" +
                    fileContent.substring(endPos)
        }

        val afterHeading = headingLineEnd + 1
        val bodyStart = skipPropertiesDrawer(section, afterHeading)

        // Reconstruct: before-heading + heading-line + props + new-body + suffix
        // NOTE: newBody is the complete replacement — it already contains any child headings
        // from the user's text field. We do NOT preserve children from the original file.
        val prefix = fileContent.substring(0, headingPosition)
        val headingAndProps = section.substring(0, bodyStart)
        val suffix = fileContent.substring(endPos)

        val normalizedBody = when {
            newBody.isEmpty() -> "\n"
            else -> newBody.trimEnd() + "\n"
        }

        return prefix + headingAndProps + normalizedBody + suffix
    }

    /**
     * Find the next heading at the same or higher level after the given position.
     *
     * @param fileContent Full file content
     * @param headingPosition Starting heading's character offset
     * @param headingLevel The level (number of `*`) of the current heading
     * @return Character offset of the next same-or-higher-level heading, or null
     */
    fun findNextHeadingPosition(
        fileContent: String,
        headingPosition: Int,
        headingLevel: Int
    ): Int? {
        // Search line by line after the heading line
        val firstNewline = fileContent.indexOf('\n', headingPosition)
        if (firstNewline == -1) return null

        var pos = firstNewline + 1
        while (pos < fileContent.length) {
            val lineEnd = fileContent.indexOf('\n', pos).let { if (it == -1) fileContent.length else it }
            val line = fileContent.substring(pos, lineEnd)

            // Check if this line is a heading at same or higher level
            // Org-mode headings: starts with *, followed by space (not bold markup like *text*)
            if (line.isNotEmpty() && line[0] == '*') {
                val stars = line.takeWhile { it == '*' }.length
                // After the stars, there must be a space for it to be a heading
                if (stars < line.length && line[stars] == ' ') {
                    if (stars in 1..headingLevel) {
                        return pos
                    }
                }
            }
            pos = lineEnd + 1
        }
        return null
    }

    /**
     * Find the end position of a heading's entire section including all children.
     * This is used for body replacement to ensure children are preserved.
     *
     * @param fileContent Full file content
     * @param headingPosition Starting heading's character offset
     * @param headingLevel The level (number of `*`) of the current heading
     * @return Character offset after the heading's content (before next sibling or end of file)
     */
    fun findHeadingEndPosition(
        fileContent: String,
        headingPosition: Int,
        headingLevel: Int
    ): Int? {
        // First try to find next sibling at same or higher level
        val nextSiblingPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
        if (nextSiblingPos != null) {
            return nextSiblingPos
        }

        // No sibling found - find position after all children
        // This means we need to find the first heading with level < headingLevel after our position
        var pos = fileContent.indexOf('\n', headingPosition)
        if (pos == -1) return null
        pos++ // Move past newline

        while (pos < fileContent.length) {
            val lineEnd = fileContent.indexOf('\n', pos).let { if (it == -1) fileContent.length else it }
            val line = fileContent.substring(pos, lineEnd)

            // Check if this is a heading at a higher level (less stars)
            // Org-mode headings: starts with *, followed by space (not bold markup like *text*)
            if (line.isNotEmpty() && line[0] == '*') {
                val stars = line.takeWhile { it == '*' }.length
                // After the stars, there must be a space for it to be a heading
                if (stars < line.length && line[stars] == ' ') {
                    if (stars < headingLevel) {
                        // Found a heading at a higher level - this marks the end of our section
                        return pos
                    }
                }
            }
            pos = lineEnd + 1
        }

        // End of file
        return fileContent.length
    }

    private fun skipPropertiesDrawer(section: String, startOffset: Int): Int {
        // Look for :PROPERTIES: at startOffset
        val remaining = section.substring(startOffset).trimStart()
        if (!remaining.startsWith(":PROPERTIES:")) {
            return startOffset
        }

        val endMarker = ":END:"
        val endIdx = section.indexOf(endMarker, startOffset)
        if (endIdx == -1) return startOffset

        val afterEnd = endIdx + endMarker.length
        // Skip the newline after :END:
        return if (afterEnd < section.length && section[afterEnd] == '\n') {
            afterEnd + 1
        } else {
            afterEnd
        }
    }

    /**
     * Strip :REVIEW_DATA: drawers from body content using the org parser.
     * These contain flashcard review metadata and shouldn't be shown to users.
     */
    fun stripReviewDataDrawers(body: String): String {
        if (body.isBlank()) return body

        return try {
            // Tokenize and parse the body content
            // Guard: OrgLexer crashes on empty input (see AGENTS.md)
            val tokens = if (body.isEmpty()) emptyList() else OrgLexer(body).tokenize()
            val parser = zeroOrMore(parseChunk)
            // Start at position 1 to skip SOF (Start Of File) token
            val startPos = if (tokens.isNotEmpty() && tokens[0] is Token.SOF) 1 else 0
            val result = parser.invoke(tokens, startPos)

            val chunks = when (result) {
                is ParsingResult.Success -> result.output
                is ParsingResult.Failure -> return body // Fall back to original on parse error
            }

            // Find all REVIEW_DATA drawer chunks and collect their ranges
            // Note: token.range is Pair<Int, Int> where second is EXCLUSIVE
            val rangesToRemove = mutableListOf<Pair<Int, Int>>()
            for (chunk in chunks) {
                if (chunk is OrgChunk.OrgReviewDataDrawer) {
                    // Get the range from the first to last token
                    if (chunk.tokens.isNotEmpty()) {
                        val start = chunk.tokens.first().range.first
                        val end = chunk.tokens.last().range.second  // exclusive
                        rangesToRemove.add(Pair(start, end))
                    }
                }
            }

            // If no drawers found, return original body
            if (rangesToRemove.isEmpty()) return body

            // Sort ranges by start position (descending) to remove from end first
            rangesToRemove.sortByDescending { it.first }

            // Remove each drawer range from the body
            var resultStr = body
            for ((start, end) in rangesToRemove) {
                // Include any trailing newline after the drawer
                val endPos = if (end < resultStr.length && resultStr[end] == '\n') {
                    end + 1
                } else {
                    end
                }
                resultStr = resultStr.removeRange(start, endPos.coerceAtMost(resultStr.length))
            }

            resultStr.trim()
        } catch (e: Exception) {
            // If parsing fails, fall back to regex-based stripping
            val reviewDataPattern = Regex(":REVIEW_DATA:[\\s\\S]*?:END:\\s*\n?")
            reviewDataPattern.replace(body, "").trim()
        }
    }

    /**
     * Extract a heading and all its children (the full subtree).
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start
     * @param headingLevel Level of the heading (number of `*`)
     * @return The full heading text including all child headings
     */
    fun extractFullHeading(
        fileContent: String,
        headingPosition: Int,
        headingLevel: Int
    ): String {
        val endPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
            ?: fileContent.length
        return fileContent.substring(headingPosition, endPos).trimEnd('\n') + "\n"
    }

    /**
     * Remove a heading and all its children from file content.
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start
     * @param headingLevel Level of the heading
     * @return Modified file content with heading removed
     */
    fun removeFullHeading(
        fileContent: String,
        headingPosition: Int,
        headingLevel: Int
    ): String {
        val endPos = findNextHeadingPosition(fileContent, headingPosition, headingLevel)
            ?: fileContent.length

        val before = fileContent.substring(0, headingPosition)
        val after = fileContent.substring(endPos)

        // Clean up extra blank lines at the join point
        val result = before.trimEnd('\n') + (if (after.isNotEmpty()) "\n" + after.trimStart('\n') else "")
        return if (result.isEmpty()) "" else result.trimEnd('\n') + "\n"
    }

    /**
     * Adjust all heading levels in content to start at a target level.
     * Preserves relative depth of nested headings.
     *
     * @param headingContent Content containing headings
     * @param targetLevel The level the top heading should become
     * @return Content with adjusted heading levels
     */
    fun adjustHeadingLevels(
        headingContent: String,
        targetLevel: Int
    ): String {
        if (targetLevel < 1) return headingContent

        // Find the minimum heading level in the content
        val headingPattern = Regex("^(\\*+) ", RegexOption.MULTILINE)
        val matches = headingPattern.findAll(headingContent).toList()
        
        if (matches.isEmpty()) {
            return headingContent
        }

        val minLevel = matches.minOf { it.groupValues[1].length }
        val levelDiff = targetLevel - minLevel

        if (levelDiff == 0) {
            return headingContent
        }

        // Delegate to offset-based function
        return adjustHeadingLevelsByOffset(headingContent, levelDiff)
    }

    /**
     * Adjust all heading levels by a fixed offset.
     * Preserves relative depth of nested headings.
     * Levels never go below 1.
     *
     * @param headingContent Content containing headings
     * @param offset The amount to adjust each level (positive = deeper, negative = shallower)
     * @return Content with adjusted heading levels
     */
    fun adjustHeadingLevelsByOffset(
        headingContent: String,
        offset: Int
    ): String {
        if (offset == 0) return headingContent

        val headingPattern = Regex("^(\\*+) ", RegexOption.MULTILINE)
        val matches = headingPattern.findAll(headingContent).toList()
        
        if (matches.isEmpty()) {
            return headingContent
        }

        // Adjust each heading line
        return headingPattern.replace(headingContent) { match ->
            val currentLevel = match.groupValues[1].length
            val newLevel = (currentLevel + offset).coerceAtLeast(1)
            "*".repeat(newLevel) + " "
        }
    }

    /**
     * Insert a heading as the last child of another heading.
     * For level-0 targets (file-level), appends at end of file.
     *
     * @param fileContent Target file content
     * @param targetHeadingPosition Position of the target heading (0 for file-level)
     * @param targetHeadingLevel Level of the target heading (0 for file-level)
     * @param headingContent Content to insert (heading levels should already be adjusted)
     * @return Modified file content
     */
    fun insertHeadingUnder(
        fileContent: String,
        targetHeadingPosition: Int,
        targetHeadingLevel: Int,
        headingContent: String
    ): String {
        // For file-level target (level 0), append at end of file
        if (targetHeadingLevel == 0) {
            val trimmed = fileContent.trimEnd('\n')
            return if (trimmed.isEmpty()) {
                headingContent
            } else {
                trimmed + "\n\n" + headingContent
            }
        }

        // Find insertion point: just before the next same-or-higher-level heading
        val insertPos = findNextHeadingPosition(fileContent, targetHeadingPosition, targetHeadingLevel)
            ?: fileContent.length

        val before = fileContent.substring(0, insertPos).trimEnd('\n')
        val after = fileContent.substring(insertPos)

        return if (after.isEmpty()) {
            before + "\n" + headingContent
        } else {
            before + "\n" + headingContent + after.trimStart('\n')
        }
    }
}

Property Drawer Utilities

PropertyDrawerUtils uses the org-mode-kmp parser to find headings by position, then performs precise string edits on property drawers. It handles create, update, and delete for heading-level properties.

kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/PropertyDrawerUtils.kt
package computer.whatthefuck.arcology.editor

import xyz.lepisma.orgmode.OrgDocument
import xyz.lepisma.orgmode.OrgHeading
import xyz.lepisma.orgmode.OrgParseResult
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseWithDetails

/**
 * Utilities for manipulating property drawers in org-mode files.
 * Uses orgmode-kmp for parsing, then performs precise string operations
 * based on token positions.
 */
object PropertyDrawerUtils {

    /**
     * Set a property value in a heading's property drawer.
     * If the property exists, it will be updated. If not, it will be added.
     * If no property drawer exists, one will be created.
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start (the `*` character)
     * @param key Property key (e.g., "GEO_COORDS")
     * @param value Property value
     * @return Modified file content, or null if the heading couldn't be found
     */
    fun setProperty(
        fileContent: String,
        headingPosition: Int,
        key: String,
        value: String
    ): String? {
        val tokens = OrgLexer(fileContent).tokenize()
        val parseResult = parseWithDetails(tokens)

        if (parseResult !is OrgParseResult.Success) {
            return null
        }

        val document = parseResult.document

        // Find the heading at the given position
        val heading = findHeadingAtPosition(document, headingPosition)
            ?: return null

        val propertyLine = ":$key: $value"

        // Check if the heading has a property drawer
        val properties = heading.properties
        if (properties != null && properties.tokens.isNotEmpty()) {
            // Get drawer position from tokens
            val drawerTokens = properties.tokens
            val drawerStart = drawerTokens.first().range.first
            val drawerEnd = drawerTokens.last().range.second

            // Check if this property already exists
            val existingValue = properties.map[key]
            if (existingValue != null) {
                // Update existing property
                return updateExistingProperty(fileContent, drawerStart, drawerEnd, key, value)
            } else {
                // Insert new property before :END:
                return insertPropertyInDrawer(fileContent, drawerEnd, propertyLine)
            }
        } else {
            // No property drawer - create one after the heading line
            return createPropertyDrawer(fileContent, heading, key, value)
        }
    }

    /**
     * Find the heading in the document that starts at the given character position.
     */
    private fun findHeadingAtPosition(document: OrgDocument, position: Int): OrgHeading? {
        // Search in document content sections
        for (section in document.content) {
            val found = findHeadingInSection(section, position)
            if (found != null) return found
        }
        return null
    }

    private fun findHeadingInSection(section: OrgSection, position: Int): OrgHeading? {
        // Check if this section's heading is at the target position
        val headingTokens = section.heading.tokens
        if (headingTokens.isNotEmpty()) {
            val headingStart = headingTokens.first().range.first
            if (headingStart == position) {
                return section.heading
            }
        }

        // Search in child sections
        for (chunk in section.body) {
            if (chunk is OrgSection) {
                val found = findHeadingInSection(chunk, position)
                if (found != null) return found
            }
        }

        return null
    }

    /**
     * Update an existing property in the drawer.
     */
    private fun updateExistingProperty(
        fileContent: String,
        drawerStart: Int,
        drawerEnd: Int,
        key: String,
        value: String
    ): String {
        val drawer = fileContent.substring(drawerStart, drawerEnd)

        // Find and replace the property line
        val propertyPattern = Regex("^(\\s*):$key:\\s*.*$", RegexOption.MULTILINE)
        val newDrawer = drawer.replace(propertyPattern) { match ->
            val indent = match.groupValues[1]
            "$indent:$key: $value"
        }

        return fileContent.substring(0, drawerStart) + newDrawer + fileContent.substring(drawerEnd)
    }

    /**
     * Insert a new property line before :END: in an existing drawer.
     */
    private fun insertPropertyInDrawer(
        fileContent: String,
        drawerEnd: Int,
        propertyLine: String
    ): String {
        // Find the :END: marker position
        // The drawerEnd is the position after :END:, so we need to find :END: before it
        val searchStart = maxOf(0, drawerEnd - 20)
        val endSection = fileContent.substring(searchStart, drawerEnd)
        val endMarkerPos = endSection.lastIndexOf(":END:")

        if (endMarkerPos == -1) {
            // Fallback: insert before drawerEnd
            return fileContent.substring(0, drawerEnd) + propertyLine + "\n" + fileContent.substring(drawerEnd)
        }

        val absoluteEndPos = searchStart + endMarkerPos

        // Find the indentation of :END:
        val lineStart = fileContent.lastIndexOf('\n', absoluteEndPos - 1) + 1
        val indent = fileContent.substring(lineStart, absoluteEndPos).takeWhile { it == ' ' || it == '\t' }

        // Insert the new property line before :END:
        return fileContent.substring(0, absoluteEndPos) +
               "$indent$propertyLine\n" +
               fileContent.substring(absoluteEndPos)
    }

    /**
     * Create a new property drawer after the heading line.
     */
    private fun createPropertyDrawer(
        fileContent: String,
        heading: OrgHeading,
        key: String,
        value: String
    ): String {
        // Find the end of the heading line
        val headingTokens = heading.tokens
        if (headingTokens.isEmpty()) return fileContent

        val headingStart = headingTokens.first().range.first

        // Find the end of the heading line (after title, tags, etc.)
        val headingEnd = findHeadingLineEnd(fileContent, headingStart)

        // Check if there's planning info (SCHEDULED, DEADLINE, etc.) that comes before properties
        val planningInfo = heading.planningInfo
        val insertPosition = if (planningInfo != null && planningInfo.tokens.isNotEmpty()) {
            // Insert after planning info
            planningInfo.tokens.last().range.second
        } else {
            headingEnd
        }

        // Create the property drawer with proper indentation
        val drawer = "\n:PROPERTIES:\n:$key: $value\n:END:"

        return fileContent.substring(0, insertPosition) +
               drawer +
               fileContent.substring(insertPosition)
    }

    /**
     * Find the position after the heading line (after the newline).
     */
    private fun findHeadingLineEnd(fileContent: String, headingStart: Int): Int {
        val newlinePos = fileContent.indexOf('\n', headingStart)
        return if (newlinePos == -1) fileContent.length else newlinePos
    }

    /**
     * Remove a property from a heading's property drawer.
     * If the property doesn't exist, returns the content unchanged.
     * If removing the property leaves an empty drawer, the drawer is also removed.
     *
     * @param fileContent Full file content
     * @param headingPosition Character offset of the heading start (the `*` character)
     * @param key Property key to remove
     * @return Modified file content, or null if the heading couldn't be found
     */
    fun removeProperty(
        fileContent: String,
        headingPosition: Int,
        key: String
    ): String? {
        val tokens = OrgLexer(fileContent).tokenize()
        val parseResult = parseWithDetails(tokens)

        if (parseResult !is OrgParseResult.Success) {
            return null
        }

        val document = parseResult.document

        // Find the heading at the given position
        val heading = findHeadingAtPosition(document, headingPosition)
            ?: return null

        // Check if the heading has a property drawer
        val properties = heading.properties
        if (properties == null || properties.tokens.isEmpty()) {
            // No property drawer, nothing to remove
            return fileContent
        }

        // Check if the property exists
        if (properties.map[key] == null) {
            // Property doesn't exist, return unchanged
            return fileContent
        }

        val drawerTokens = properties.tokens
        val drawerStart = drawerTokens.first().range.first
        val drawerEnd = drawerTokens.last().range.second

        // If this is the only property, remove the entire drawer
        if (properties.map.size == 1) {
            // Find the start of the line containing :PROPERTIES:
            val lineStart = fileContent.lastIndexOf('\n', drawerStart - 1)
            val actualStart = if (lineStart == -1) 0 else lineStart

            // Find the end of the line containing :END:
            val lineEnd = fileContent.indexOf('\n', drawerEnd)
            val actualEnd = if (lineEnd == -1) drawerEnd else lineEnd

            return fileContent.substring(0, actualStart) + fileContent.substring(actualEnd)
        }

        // Remove just this property line
        val drawer = fileContent.substring(drawerStart, drawerEnd)
        val propertyPattern = Regex("^\\s*:$key:\\s*.*\\n?", RegexOption.MULTILINE)
        val newDrawer = drawer.replace(propertyPattern, "")

        return fileContent.substring(0, drawerStart) + newDrawer + fileContent.substring(drawerEnd)
    }
}

Review Data Drawer Utilities

ReviewDataDrawerUtils manages REVIEW_DATA drawers — org-mode tables storing flashcard review metadata. It formats rows, creates drawers, updates existing ones, and handles both file-level and heading-level placement.

kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/ReviewDataDrawerUtils.kt
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.flashcard.ReviewData
import kotlin.time.Instant

/**
 * Utilities for manipulating REVIEW_DATA drawers in org-mode files.
 * REVIEW_DATA drawers store spaced repetition metadata for flashcards.
 *
 * Format:
 * :REVIEW_DATA:
 * | position | ease | box | interval | due | review_count |
 * |----------+------+-----+----------+-----+--------------|
 * | front    | 2.50 |   1 |      1.0 | 2025-01-15T10:00:00Z | 5 |
 * :END:
 */
object ReviewDataDrawerUtils {

    /**
     * Format a ReviewData object as a table row.
     * The due date is formatted as ISO 8601 without milliseconds (org-fc compatible).
     * Interval is rounded to 2 decimal places.
     */
    fun formatReviewDataRow(reviewData: ReviewData): String {
        val dueDateStr = reviewData.dueDate?.let {
            it.toString().removeSuffix(".000Z").removeSuffix("Z") + "Z"
        } ?: ""
        val intervalStr = reviewData.intervalDays?.let { "%.2f".format(it) } ?: ""
        return "| ${reviewData.positionName} | ${reviewData.easeFactor ?: ""} | ${reviewData.box ?: ""} | $intervalStr | $dueDateStr |"
    }

    /**
     * Create the table header for REVIEW_DATA.
     */
    fun formatTableHeader(): String {
        return "| position | ease | box | interval | due |"
    }

    /**
     * Create the table separator line.
     */
    fun formatTableSeparator(): String {
        return "|----------+------+-----+----------+------|"
    }

    /**
     * Create a new REVIEW_DATA drawer with a single row.
     */
    fun createDrawer(reviewData: ReviewData): String {
        return ":REVIEW_DATA:\n${formatTableHeader()}\n${formatTableSeparator()}\n${formatReviewDataRow(reviewData)}\n:END:\n"
    }

    /**
     * Set or update REVIEW_DATA in file content for a file-level node.
     * Creates or updates the :REVIEW_DATA: drawer at the start of the file.
     *
     * @param content Full file content
     * @param reviewData Review data to add/update
     * @return Modified file content
     */
    fun setFileLevelReviewData(content: String, reviewData: ReviewData): String {
        val trimmed = content.trimStart()

        // Look for existing :REVIEW_DATA: drawer
        val reviewDataDrawerPattern = Regex(":REVIEW_DATA:\\s*\n", RegexOption.MULTILINE)
        val drawerMatch = reviewDataDrawerPattern.find(trimmed)

        return if (drawerMatch != null) {
            // Found existing REVIEW_DATA drawer - update it
            updateExistingDrawer(content, reviewData)
        } else {
            // No REVIEW_DATA drawer - create one after :PROPERTIES: drawer or at the start
            createNewDrawer(content, reviewData)
        }
    }

    /**
     * Set or update REVIEW_DATA in file content for a heading-level node.
     * Creates or updates the :REVIEW_DATA: drawer after the heading's :PROPERTIES: drawer.
     *
     * @param content Full file content
     * @param headingPosition Character offset of the heading start
     * @param reviewData Review data to add/update
     * @return Modified file content, or null if heading couldn't be found
     */
    fun setHeadingLevelReviewData(
        content: String,
        headingPosition: Int,
        reviewData: ReviewData
    ): String? {
        // Find the end of the heading line
        val newlineAfterHeading = content.indexOf('\n', headingPosition)
        if (newlineAfterHeading == -1) return null

        // Find the :PROPERTIES: drawer end (if any) or use end of heading line
        val propertiesEndPattern = Regex(":PROPERTIES:[\\s\\S]*?:END:\\s*\n")
        val searchStart = newlineAfterHeading + 1
        val searchRegion = content.substring(searchStart, minOf(searchStart + 500, content.length))
        val propsMatch = propertiesEndPattern.find(searchRegion)

        val insertPosition = if (propsMatch != null) {
            // Insert after :PROPERTIES: drawer
            searchStart + propsMatch.range.last + 1
        } else {
            // Insert after heading line
            newlineAfterHeading + 1
        }

        // Check if there's already a :REVIEW_DATA: drawer in this heading's content
        val headingEnd = findNextHeadingPosition(content, headingPosition)
        val headingContent = content.substring(insertPosition, headingEnd)

        val reviewDataDrawerPattern = Regex(":REVIEW_DATA:\\s*\n")
        val existingDrawerMatch = reviewDataDrawerPattern.find(headingContent)

        return if (existingDrawerMatch != null) {
            // Update existing drawer
            val drawerStart = insertPosition + existingDrawerMatch.range.first
            updateExistingDrawerInRange(content, drawerStart, reviewData)
        } else {
            // Create new drawer
            val drawer = createDrawer(reviewData)
            content.substring(0, insertPosition) + drawer + content.substring(insertPosition)
        }
    }

    /**
     * Update an existing REVIEW_DATA drawer by adding/updating a row.
     */
    private fun updateExistingDrawer(content: String, reviewData: ReviewData): String {
        val drawerStartPattern = Regex(":REVIEW_DATA:\\s*\n")
        val drawerStartMatch = drawerStartPattern.find(content) ?: return content

        val drawerStart = drawerStartMatch.range.first
        return updateExistingDrawerInRange(content, drawerStart, reviewData)
    }

    /**
     * Update REVIEW_DATA drawer starting at the given position.
     */
    private fun updateExistingDrawerInRange(
        content: String,
        drawerStart: Int,
        reviewData: ReviewData
    ): String {
        // Find :END: after the drawer start
        val afterDrawerStart = content.substring(drawerStart)
        val endPattern = Regex(":END:\\s*\n?")
        val endMatch = endPattern.find(afterDrawerStart) ?: return content

        val drawerEnd = drawerStart + endMatch.range.last + 1
        val drawerContent = content.substring(drawerStart, drawerEnd)

        // Check if position already exists in table
        val escapedPosition = Regex.escape(reviewData.positionName)
        val positionPattern = Regex("^\\s*\\|\\s*${escapedPosition}\\s*\\|.*$", RegexOption.MULTILINE)
        val rowMatch = positionPattern.find(drawerContent)

        val newRow = formatReviewDataRow(reviewData)

        return if (rowMatch != null) {
            // Update existing row
            val updatedDrawer = drawerContent.replace(rowMatch.value, newRow)
            content.substring(0, drawerStart) + updatedDrawer + content.substring(drawerEnd)
        } else {
            // Insert new row before :END:
            val beforeEnd = content.substring(0, drawerEnd)
            val afterEnd = content.substring(drawerEnd)
            // Find the last data row (skip header and separator)
            val lines = drawerContent.lines()
            val lastDataRowIdx = lines.indexOfLast { it.trim().startsWith("|") && !it.trim().startsWith("|-") && !it.contains("position") }
            if (lastDataRowIdx >= 0) {
                // Insert after the last data row
                val insertLineIdx = drawerStart + lines.take(lastDataRowIdx + 1).sumOf { it.length + 1 }
                content.substring(0, insertLineIdx) + newRow + "\n" + content.substring(insertLineIdx)
            } else {
                // Insert after separator
                beforeEnd + newRow + "\n" + afterEnd
            }
        }
    }

    /**
     * Create a new REVIEW_DATA drawer after :PROPERTIES: or at the start.
     */
    private fun createNewDrawer(content: String, reviewData: ReviewData): String {
        val trimmed = content.trimStart()
        val drawer = createDrawer(reviewData)

        // Check if file starts with :PROPERTIES:
        if (trimmed.startsWith(":PROPERTIES:")) {
            // Find end of :PROPERTIES: drawer
            val propsEndPattern = Regex(":PROPERTIES:[\\s\\S]*?:END:\\s*\n?")
            val propsMatch = propsEndPattern.find(trimmed)

            if (propsMatch != null) {
                // Insert after :PROPERTIES: drawer
                val insertPos = content.indexOf(":END:") + ":END:".length
                // Skip trailing newline if present
                val actualInsertPos = if (insertPos < content.length && content[insertPos] == '\n') {
                    insertPos + 1
                } else {
                    insertPos
                }
                return content.substring(0, actualInsertPos) + drawer + content.substring(actualInsertPos)
            }
        }

        // Insert at the start of file
        return drawer + "\n" + content
    }

    /**
     * Find the position of the next heading at same or higher level.
     */
    private fun findNextHeadingPosition(content: String, headingPosition: Int): Int {
        // Get the heading level
        val headingLine = content.substring(headingPosition).lines().firstOrNull() ?: return content.length
        val stars = headingLine.takeWhile { it == '*' }.length

        // Search for next heading at same or higher level
        var pos = headingPosition + headingLine.length + 1
        while (pos < content.length) {
            val nextNewline = content.indexOf('\n', pos)
            val lineEnd = if (nextNewline == -1) content.length else nextNewline
            val line = content.substring(pos, lineEnd)

            if (line.isNotEmpty() && line[0] == '*') {
                val nextStars = line.takeWhile { it == '*' }.length
                if (nextStars in 1..stars) {
                    return pos
                }
            }
            pos = lineEnd + 1
        }
        return content.length
    }
}

Planning Info Utilities

PlanningInfoUtils performs raw string surgery on SCHEDULED/DEADLINE/CLOSED planning lines that live between the heading line and the :PROPERTIES: drawer. It also implements repeater rescheduling: when a heading with a repeater enters a done state, the planning timestamp is advanced and the TODO keyword is flipped back to an active state. This mirrors org-mode's org-auto-repeat-mode behavior.

The object is pure — it takes file content and a heading position, returns transformed content. This makes it unit-testable without FileSystemInterface or RoamRepository. It follows the same pattern as PropertyDrawerUtils.

Position of planning lines

The org-mode parser (parser.org parseHeading at OrgSection.kt:225) expects planning info to appear after the heading line and before the :PROPERTIES: drawer:

,* TODO Task
SCHEDULED: <2026-08-15 Sat 14:00 +1w>
:PROPERTIES:
:ID: task-id
:END:

All insert/replace operations preserve this ordering.

Repeater semantics

The three repeater types (defined in Repeater.kt) control how the timestamp advances:

Type Syntax Behavior
EXACT +1d Shift from original date. Backlog allowed (new date may be in the past).
CUMULATE ++1d Shift from today. No backlog.
CATCH_UP .+1d Shift from original, keep adding until new date >= today.

When a done state is entered on a heading with a repeater:

  1. The planning timestamp is advanced per the repeater type.

  2. The TODO keyword is flipped back to an active state. REPEAT_TO_STATE property overrides the default TODO target.

  3. A LOGBOOK state-change entry is still recorded (done → reschedule target).

kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/PlanningInfoUtils.kt
package computer.whatthefuck.arcology.editor

import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.plus
import xyz.lepisma.orgmode.OrgDocument
import xyz.lepisma.orgmode.OrgLine
import xyz.lepisma.orgmode.OrgParseResult
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.Repeater
import xyz.lepisma.orgmode.RepeaterType
import xyz.lepisma.orgmode.TimeUnit
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseRepeater
import xyz.lepisma.orgmode.parseWithDetails
import xyz.lepisma.orgmode.plainText

enum class PlanningKind(val keyword: String) {
    SCHEDULED("SCHEDULED"),
    DEADLINE("DEADLINE"),
    CLOSED("CLOSED")
}

data class RescheduleResult(
    val newContent: String,
    val newTodoState: String,
    val rescheduledKind: PlanningKind
)

object PlanningInfoUtils {

    private val WEEKDAY_ABBREV = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")

    /**
     * Insert, replace, or remove a planning line for the heading at [headingPosition].
     *
     * @param content Full file content
     * @param headingPosition Character offset of the heading start (the `*` character)
     * @param kind Which planning keyword to set
     * @param timestampString Full org timestamp e.g. "<2026-08-15 Sat 14:00 +1w>" or null to remove
     * @return Modified content, or null if the heading couldn't be found
     */
    fun updatePlanningLine(
        content: String,
        headingPosition: Int,
        kind: PlanningKind,
        timestampString: String?
    ): String? {
        val headingEnd = findHeadingLineEnd(content, headingPosition)
        // Start the planning region after the heading's trailing newline so the
        // regex below matches the SCHEDULED/DEADLINE line itself, not the newline
        // before it (which would cause the newline to be eaten on replace/remove).
        val regionStart = if (headingEnd < content.length && content[headingEnd] == '\n') headingEnd + 1 else headingEnd

        // Search for existing planning line between regionStart and :PROPERTIES: or blank line
        val planningRegionEnd = findPlanningRegionEnd(content, headingEnd)
        val planningRegion = content.substring(regionStart, planningRegionEnd)

        val linePattern = Regex("^\\s*${kind.keyword}:\\s+.*$", RegexOption.MULTILINE)
        val existingMatch = linePattern.find(planningRegion)

        if (timestampString == null) {
            // Remove mode
            if (existingMatch == null) return content
            val absoluteStart = regionStart + existingMatch.range.first
            val absoluteEnd = regionStart + existingMatch.range.last + 1
            // Also consume the trailing newline
            val removeEnd = if (absoluteEnd < content.length && content[absoluteEnd] == '\n') absoluteEnd + 1 else absoluteEnd
            return content.substring(0, absoluteStart) + content.substring(removeEnd)
        }

        val newLine = "${kind.keyword}: $timestampString"

        return if (existingMatch != null) {
            // Replace existing line
            val absoluteStart = regionStart + existingMatch.range.first
            val absoluteEnd = regionStart + existingMatch.range.last + 1
            content.substring(0, absoluteStart) + newLine + content.substring(absoluteEnd)
        } else {
            // Insert new line after heading line, before planning region content
            content.substring(0, regionStart) + newLine + "\n" + content.substring(regionStart)
        }
    }

    /**
     * Read the raw planning line string for [kind] at the heading, or null if not present.
     * Returns the full line e.g. "SCHEDULED: <2026-08-15 Sat 14:00 +1w>"
     */
    fun readPlanningLine(content: String, headingPosition: Int, kind: PlanningKind): String? {
        val headingEnd = findHeadingLineEnd(content, headingPosition)
        val regionStart = if (headingEnd < content.length && content[headingEnd] == '\n') headingEnd + 1 else headingEnd
        val planningRegionEnd = findPlanningRegionEnd(content, headingEnd)
        val planningRegion = content.substring(regionStart, planningRegionEnd)

        val linePattern = Regex("^\\s*${kind.keyword}:\\s+.*$", RegexOption.MULTILINE)
        return linePattern.find(planningRegion)?.value?.trim()
    }

    /**
     * Compute a reschedule: if the heading at [headingPosition] has a repeater on its
     * SCHEDULED or DEADLINE planning line, advance the timestamp and determine the
     * new TODO state to flip to.
     *
     * SCHEDULED takes precedence over DEADLINE for rescheduling.
     *
     * @param content Full file content
     * @param headingPosition Character offset of the heading start
     * @param today Current date for CUMULATE/CATCH_UP calculations
     * @param defaultRepeatToState TODO state to reset to when no =REPEAT_TO_STATE= property is set (defaults to "TODO")
     * @return RescheduleResult with shifted content and new TODO state, or null if no repeater
     */
    fun computeReschedule(
        content: String,
        headingPosition: Int,
        today: LocalDate,
        defaultRepeatToState: String = "TODO"
    ): RescheduleResult? {
        val tokens = OrgLexer(content).tokenize()
        val parseResult = parseWithDetails(tokens)
        if (parseResult !is OrgParseResult.Success) return null

        val document = parseResult.document
        val section = findHeadingAtPosition(document, headingPosition) ?: return null
        val planning = section.heading.planningInfo ?: return null

        val scheduled = planning.scheduled
        val deadline = planning.deadline

        val (stamp, kind) = when {
            scheduled?.repeater != null -> scheduled to PlanningKind.SCHEDULED
            deadline?.repeater != null -> deadline to PlanningKind.DEADLINE
            else -> return null
        }

        val repeater = stamp.parseRepeater() ?: return null
        val newDate = advanceDate(stamp.date, repeater, today)
        val newTimestampStr = formatTimestamp(
            date = newDate,
            time = stamp.time?.first,
            isActive = stamp.isActive,
            repeater = stamp.repeater,
            showWeekday = stamp.showWeekDay
        )

        val newContent = updatePlanningLine(content, headingPosition, kind, newTimestampStr)
            ?: return null

        val repeatToState = section.heading.properties?.map?.get("REPEAT_TO_STATE")?.plainText()
            ?: defaultRepeatToState

        return RescheduleResult(
            newContent = newContent,
            newTodoState = repeatToState,
            rescheduledKind = kind
        )
    }

    /**
     * Advance a date according to a repeater, relative to [today].
     */
    internal fun advanceDate(original: LocalDate, repeater: Repeater, today: LocalDate): LocalDate {
        val shifted = shiftDate(original, repeater.value, repeater.unit)
        return when (repeater.type) {
            RepeaterType.EXACT -> shifted
            RepeaterType.CUMULATE -> shiftDate(today, repeater.value, repeater.unit)
            RepeaterType.CATCH_UP -> {
                var result = shifted
                while (result < today) {
                    result = shiftDate(result, repeater.value, repeater.unit)
                }
                result
            }
        }
    }

    private fun shiftDate(date: LocalDate, value: Int, unit: TimeUnit): LocalDate {
        return when (unit) {
            TimeUnit.DAYS -> date.plus(DatePeriod(days = value))
            TimeUnit.WEEKS -> date.plus(DatePeriod(days = value * 7))
            TimeUnit.MONTHS -> date.plus(DatePeriod(months = value))
            TimeUnit.YEARS -> date.plus(DatePeriod(years = value))
            TimeUnit.HOURS -> date // Hours don't change the date; handled at time level
        }
    }

    /**
     * Format a timestamp string in org-mode syntax.
     * e.g. "<2026-08-15 Sat 14:00 +1w>"
     */
    fun formatTimestamp(
        date: LocalDate,
        time: LocalTime?,
        isActive: Boolean,
        repeater: String?,
        showWeekday: Boolean
    ): String {
        val open = if (isActive) '<' else '['
        val close = if (isActive) '>' else ']'
        val parts = mutableListOf<String>()
        parts.add(date.toString())
        if (showWeekday) {
            parts.add(WEEKDAY_ABBREV[date.dayOfWeek.ordinal])
        }
        if (time != null) {
            parts.add("${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}")
        }
        if (repeater != null) {
            parts.add(repeater)
        }
        return "$open${parts.joinToString(" ")}$close"
    }

    private fun findHeadingLineEnd(content: String, headingStart: Int): Int {
        val newlinePos = content.indexOf('\n', headingStart)
        return if (newlinePos == -1) content.length else newlinePos
    }

    /**
     * Find the end of the planning region: the position of :PROPERTIES: drawer,
     * or the first blank line after the heading, or end of file.
     */
    private fun findPlanningRegionEnd(content: String, headingEnd: Int): Int {
        var pos = headingEnd
        // Skip past the newline at headingEnd if present
        if (pos < content.length && content[pos] == '\n') pos++

        while (pos < content.length) {
            val lineEnd = content.indexOf('\n', pos).let { if (it == -1) content.length else it }
            val line = content.substring(pos, lineEnd).trim()

            if (line.isEmpty()) return pos // blank line ends the region
            if (line.startsWith(":PROPERTIES:")) return pos
            if (line.startsWith(":LOGBOOK:")) return pos
            // Another heading
            if (line.isNotEmpty() && line[0] == '*') return pos

            pos = lineEnd + 1
        }
        return content.length
    }

    private fun findHeadingAtPosition(document: OrgDocument, position: Int): OrgSection? {
        for (section in document.content) {
            val found = findSectionAt(section, position)
            if (found != null) return found
        }
        return null
    }

    private fun findSectionAt(section: OrgSection, position: Int): OrgSection? {
        val headingTokens = section.heading.tokens
        if (headingTokens.isNotEmpty()) {
            val headingStart = headingTokens.first().range.first
            if (headingStart == position) return section
        }
        for (chunk in section.body) {
            if (chunk is OrgSection) {
                val found = findSectionAt(chunk, position)
                if (found != null) return found
            }
        }
        return null
    }
}

Node Content Parser

NodeContentParser is the read counterpart to OrgDocumentEditor (the write interface). It segments a heading's content into navigable pieces — text chunks and collapsible child headings — for "narrowed display" in the mobile UI.

kotlin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/editor/NodeContentParser.kt
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.OrgNode
import xyz.lepisma.orgmode.*
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.lexer.Token

/**
 * Represents one entry in a breadcrumb navigation path.
 * Includes both nodes with IDs (navigable) and id-less headings (display-only).
 */
data class BreadcrumbEntry(
    val nodeId: String?,  // null for id-less headings (not navigable)
    val title: String,
    val level: Int        // 0 = file-level, >0 = heading level
)

/**
 * Represents a segment of node content for narrowed display.
 * When viewing a node, its content is segmented into:
 * - Text segments: Regular content that belongs to this node
 * - CollapsedNode segments: Child headings with their own :ID: that are rendered as collapsed cards
 */
sealed class NodeContentSegment {
    /**
     * Regular text content belonging to the current node.
     * This includes paragraphs, lists, blocks, etc. but NOT child headings with IDs.
     */
    data class Text(
        val content: String,
        val startOffset: Int,
        val endOffset: Int
    ) : NodeContentSegment()

    /**
     * A child heading rendered as a collapsed card.
     * If nodeId is non-null, the heading has its own :ID: and can be navigated to.
     * If nodeId is null, the heading is just a structural element that can be expanded but not navigated.
     */
    data class CollapsedNode(
        val nodeId: String?,  // null if heading has no :ID: property
        val title: String,
        val content: String,
        val level: Int,
        val startOffset: Int,
        val endOffset: Int
    ) : NodeContentSegment()
}

/**
 * Parses node content and identifies child headings with their own :ID: properties.
 * Used to implement "node narrowing" - showing only owned content with child nodes as collapsed blocks.
 */
class NodeContentParser(private val repository: RoamRepository) {

    /**
     * Parse node content and identify sections that should be collapsed.
     *
     * @param fileContent Full content of the org file
     * @param headingPosition Character offset where the current heading starts
     * @param nextHeadingPosition Character offset of the next same-or-higher-level heading, or null for end of file
     * @param currentNodeLevel The level of the current node (number of stars)
     * @return List of segments representing the node's content
     */
    suspend fun parseNodeContent(
        fileContent: String,
        headingPosition: Int,
        nextHeadingPosition: Int?,
        currentNodeLevel: Int
    ): List<NodeContentSegment> {
        val tokens = OrgLexer(fileContent).tokenize()
        val parseResult = parseWithDetails(tokens)
        if (parseResult is OrgParseResult.Failure) {
            // Fallback to minimal implementation if parsing fails
            println("W: Failed to parse org file content: ${parseResult.error}");
            val endPos = nextHeadingPosition ?: fileContent.length
            val content = fileContent.substring(headingPosition, endPos).trimEnd('\n')
            return if (content.isNotEmpty()) {
                listOf(NodeContentSegment.Text(content, headingPosition, endPos))
            } else {
                emptyList()
            }
        }

        val doc = (parseResult as OrgParseResult.Success).document

        // Find the section corresponding to headingPosition
        val targetSection = if (currentNodeLevel == 0) null else findSectionAt(doc.content, headingPosition)

        val childSections = if (targetSection == null) {
            if (currentNodeLevel == 0) doc.content else emptyList()
        } else {
            targetSection.body.filterIsInstance<OrgSection>()
        }

        val segments = mutableListOf<NodeContentSegment>()
        val bodyItems = if (targetSection == null) {
            // Preamble and Preface as initial text chunks
            val preamblePreface = mutableListOf<OrgChunk>()
            if (doc.preamble.tokens.isNotEmpty()) {
                preamblePreface.add(OrgChunk.OrgParagraph(emptyList(), doc.preamble.tokens))
            }
            if (doc.preface.tokens.isNotEmpty()) {
                preamblePreface.add(OrgChunk.OrgParagraph(emptyList(), doc.preface.tokens))
            }
            preamblePreface + doc.content
        } else {
            targetSection.body
        }

        var currentTextTokens = mutableListOf<Token>()

        for (item in bodyItems) {
            if (item is OrgSection) {
                // Flush text
                if (currentTextTokens.isNotEmpty()) {
                    val text = currentTextTokens.joinToString("") { it.text }
                    val trimmed = text.trimEnd('\n')
                    if (trimmed.isNotEmpty()) {
                        segments.add(NodeContentSegment.Text(
                            trimmed,
                            currentTextTokens.first().range.first,
                            currentTextTokens.last().range.second
                        ))
                    }
                    currentTextTokens = mutableListOf<Token>()
                }

                // Add child node
                val id = item.heading.properties?.map?.get("ID")?.plainText()
                val title = item.heading.title.plainText()
                val bodyContent = item.body.filter { it !is OrgSection }
                    .joinToString("") { chunk -> chunk.tokens.joinToString("") { it.text } }
                    .trim()

                segments.add(NodeContentSegment.CollapsedNode(
                    nodeId = id,
                    title = title,
                    content = bodyContent,
                    level = item.heading.level.level,
                    startOffset = item.heading.tokens.first().range.first,
                    endOffset = item.tokens.last().range.second
                ))
            } else {
                currentTextTokens.addAll(item.tokens)
            }
        }

        // Final flush
        if (currentTextTokens.isNotEmpty()) {
            val text = currentTextTokens.joinToString("") { it.text }
            val trimmed = text.trimEnd('\n')
            if (trimmed.isNotEmpty()) {
                segments.add(NodeContentSegment.Text(
                    trimmed,
                    currentTextTokens.first().range.first,
                    currentTextTokens.last().range.second
                ))
            }
        }

        return segments
    }

    private fun findSectionAt(sections: List<OrgSection>, position: Int): OrgSection? {
        for (section in sections) {
            val start = section.heading.tokens.first().range.first
            val end = section.tokens.last().range.second
            if (position in start until end) {
                val subSection = findSectionAt(section.body.filterIsInstance<OrgSection>(), position)
                return subSection ?: section
            }
        }
        return null
    }

    /**
     * Build breadcrumb entries from the file-level root through heading ancestors to the current node.
     * Uses outlinePath to correctly include id-less headings that don't have OrgNode records in the DB.
     * 
     * @return List of BreadcrumbEntry from root to immediate parent (excludes current node)
     */
    suspend fun getParentNodes(nodeId: String): List<BreadcrumbEntry> {
        val node = repository.getNodeById(nodeId) ?: return emptyList()
        val fileNodes = repository.getNodesByFile(node.file).sortedBy { it.position }

        val result = mutableListOf<BreadcrumbEntry>()

        val fileNode = fileNodes.find { it.level == 0 }
        if (fileNode != null) {
            result.add(BreadcrumbEntry(
                nodeId = fileNode.id,
                title = fileNode.title ?: fileNode.file.substringAfterLast("/"),
                level = 0
            ))
        }

        val headingLevel = node.outlinePath.size
        for (prefixSize in 1 until headingLevel) {
            val prefix = node.outlinePath.take(prefixSize)
            val matchingNode = fileNodes.find { it.outlinePath == prefix }
            if (matchingNode != null) {
                result.add(BreadcrumbEntry(
                    nodeId = matchingNode.id,
                    title = matchingNode.title ?: "Untitled",
                    level = matchingNode.level
                ))
            } else {
                result.add(BreadcrumbEntry(
                    nodeId = null,
                    title = prefix.last(),
                    level = prefixSize
                ))
            }
        }

        return result
    }
}

private data class ChildNodeInfo(
    val nodeId: String?,
    val title: String,
    val content: String,
    val level: Int,
    val startOffset: Int,
    val endOffset: Int
)

Tests

HeadingTextUtilsTest

Tests heading level adjustment (adjustHeadingLevelsByOffset), body replacement (replaceHeadingBody), and REVIEW_DATA drawer stripping (stripReviewDataDrawers). Covers edge cases: zero/minimum levels, empty content, TODO states, tags, properties drawers, real-world org-roam examples, and level-0 file-level node body replacement including sub-heading preservation semantics.

kotlin#+name: editor-test-heading-text-utils:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/HeadingTextUtilsTest.kt
package computer.whatthefuck.arcology.editor

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class HeadingTextUtilsTest {

    // ============ adjustHeadingLevelsByOffset Tests ============

    @Test
    fun testAdjustHeadingLevelsByOffset_negativeOne() {
        val content = """
            *** Level 3
            Content for level 3.

            **** Level 4
            Content for level 4.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("** Level 3"), "Level 3 should become level 2")
        assertTrue(result.contains("*** Level 4"), "Level 4 should become level 3")
        assertTrue(!result.contains("*** Level 3"), "Original level 3 should be gone")
        assertTrue(!result.contains("**** Level 4"), "Original level 4 should be gone")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_negativeTwo() {
        val content = """
            **** Level 4
            Content.

            ***** Level 5
            More content.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -2)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("** Level 4"), "Level 4 should become level 2")
        assertTrue(result.contains("*** Level 5"), "Level 5 should become level 3")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_positiveOne() {
        val content = """
            * Level 1
            Content.

            ** Level 2
            More content.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, 1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("** Level 1"), "Level 1 should become level 2")
        assertTrue(result.contains("*** Level 2"), "Level 2 should become level 3")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_zero() {
        val content = """
            * Level 1
            ** Level 2
            *** Level 3
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, 0)

        assertEquals(content, result, "Offset 0 should return unchanged content")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_preservesBodyContent() {
        val content = """
            ** Parent
            Some body text here.

            *** Child
            Child body with *bold* text and [[links]].

            More content.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("* Parent"), "Parent should become level 1")
        assertTrue(result.contains("** Child"), "Child should become level 2")
        assertTrue(result.contains("Some body text here."), "Body text should be preserved")
        assertTrue(result.contains("Child body with *bold* text and [[links]]."), "Child body should be preserved")
        assertTrue(result.contains("More content."), "Trailing content should be preserved")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_preservesTODOAndTags() {
        val content = """
            ** TODO Task :work:urgent:
            Task description.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("* TODO Task :work:urgent:"), "TODO and tags should be preserved")
        assertTrue(result.contains("Task description."), "Body should be preserved")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_complexHierarchy() {
        val content = """
            *** Grandparent
            Grandparent body.

            **** Parent
            Parent body.

            ***** Child
            Child body.

            ****** Grandchild
            Grandchild body.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -2)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("* Grandparent"), "Level 3 should become level 1")
        assertTrue(result.contains("** Parent"), "Level 4 should become level 2")
        assertTrue(result.contains("*** Child"), "Level 5 should become level 3")
        assertTrue(result.contains("**** Grandchild"), "Level 6 should become level 4")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_noHeadings() {
        val content = """
            Just some text content.
            No headings here.

            More text.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        assertEquals(content, result, "Content without headings should be unchanged")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_emptyContent() {
        val result = HeadingTextUtils.adjustHeadingLevelsByOffset("", -1)
        assertEquals("", result, "Empty content should return empty")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_minimumLevel() {
        // Test that levels don't go below 1
        val content = """
            * Level 1
            Content.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        // Level 1 with offset -1 should stay at level 1 (minimum)
        assertTrue(result.contains("* Level 1"), "Level 1 should stay at level 1 (minimum)")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_mixedWithMinimum() {
        val content = """
            * Level 1
            Content.

            ** Level 2
            More.

            *** Level 3
            Even more.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("* Level 1"), "Level 1 should stay at level 1")
        assertTrue(result.contains("* Level 2"), "Level 2 should become level 1")
        assertTrue(result.contains("** Level 3"), "Level 3 should become level 2")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_preservesProperties() {
        val content = """
            ** Heading with properties
            :PROPERTIES:
            :ID: 12345
            :CUSTOM: value
            :END:

            Body content.
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("* Heading with properties"), "Heading level should be adjusted")
        assertTrue(result.contains(":PROPERTIES:"), "Properties drawer should be preserved")
        assertTrue(result.contains(":ID: 12345"), "ID property should be preserved")
        assertTrue(result.contains(":CUSTOM: value"), "Custom property should be preserved")
        assertTrue(result.contains("Body content."), "Body should be preserved")
    }

    @Test
    fun testAdjustHeadingLevelsByOffset_realWorldExample() {
        // Real-world example: viewing a level 2 node, its children (level 3) should become level 2
        val content = """
            Japanese tea

            *** Reading                                                            :fc:
            :PROPERTIES:
            :ID:       20220921T114332.538092
            :FC_CREATED: 2022-09-21T18:43:32Z
            :FC_TYPE:  normal
            :END:

            まっちゃ

            *** 抹                                                                 :fc:
            :PROPERTIES:
            :ID:       20220921T114335.523098
            :END:

            rub, paint, erase
        """.trimIndent()

        val result = HeadingTextUtils.adjustHeadingLevelsByOffset(content, -1)

        println("Input:\n$content")
        println("Output:\n$result")

        assertTrue(result.contains("** Reading"), "Level 3 should become level 2")
        assertTrue(result.contains("** 抹"), "Level 3 should become level 2")
        assertTrue(result.contains(":fc:"), "Tags should be preserved")
        assertTrue(result.contains("まっちゃ"), "Body text should be preserved")
        assertTrue(!result.contains("*** Reading"), "Original level 3 should be gone")
    }

    @Test
    fun testStripReviewDataDrawers_withSingleDrawer() {
        val body = """
            Some question text here.

            :REVIEW_DATA:
            |position|ease|box|interval|due|
            |front|2.5|1|1|2026-03-01|
            :END:

            More content after.
        """.trimIndent()

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("Input:\n$body")
        println("Output:\n$result")

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
        assertTrue(!result.contains("|position|ease|"), "Should not contain drawer content")
        assertTrue(result.contains("Some question text here."), "Should retain question text")
        assertTrue(result.contains("More content after."), "Should retain content after drawer")
    }

    @Test
    fun testStripReviewDataDrawers_withMultipleDrawers() {
        val body = """
            Question text.

            :REVIEW_DATA:
            |position|ease|box|interval|due|
            |front|2.5|1|1|2026-03-01|
            :END:

            Some middle content.

            :REVIEW_DATA:
            |position|ease|box|interval|due|
            |back|2.5|1|1|2026-03-01|
            :END:

            Final content.
        """.trimIndent()

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("Input:\n$body")
        println("Output:\n$result")

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain any REVIEW_DATA drawer markers")
        assertTrue(result.contains("Question text."), "Should retain question text")
        assertTrue(result.contains("Some middle content."), "Should retain middle content")
        assertTrue(result.contains("Final content."), "Should retain final content")
    }

    @Test
    fun testStripReviewDataDrawers_noDrawer() {
        val body = """
            Just some regular content.
            No drawer here.
        """.trimIndent()

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        assertEquals(body.trim(), result, "Should return original content when no drawer present")
    }

    @Test
    fun testStripReviewDataDrawers_emptyBody() {
        val result = HeadingTextUtils.stripReviewDataDrawers("")
        assertEquals("", result)
    }

    @Test
    fun testStripReviewDataDrawers_blankBody() {
        val result = HeadingTextUtils.stripReviewDataDrawers("   \n\n  ")
        assertEquals("", result.trim())
    }

    @Test
    fun testStripReviewDataDrawers_drawerAtStart() {
        val body = """:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:

Content after drawer."""

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("=== testStripReviewDataDrawers_drawerAtStart ===")
        println("Input:\n$body")
        println("Output:\n$result")
        println("Result contains REVIEW_DATA: ${result.contains(":REVIEW_DATA:")}")

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker but got: $result")
        assertTrue(result.contains("Content after drawer."), "Should retain content after drawer")
    }

    @Test
    fun testStripReviewDataDrawers_drawerAtEnd() {
        val body = """Content before drawer.

:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:"""

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("Input:\n$body")
        println("Output:\n$result")

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
        assertTrue(result.contains("Content before drawer."), "Should retain content before drawer")
    }

    @Test
    fun testStripReviewDataDrawers_onlyDrawer() {
        val body = """:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:"""

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("Input:\n$body")
        println("Output:\n$result")

        assertEquals("", result, "Should return empty string when only drawer present")
    }

    @Test
    fun testStripReviewDataDrawers_preservesOtherDrawers() {
        val body = """
            Question text.

            :LOGBOOK:
            CLOCK: [2026-03-01 Sat 10:00]--[2026-03-01 Sat 11:00] => 1:00
            :END:

            :REVIEW_DATA:
            |position|ease|box|interval|due|
            |front|2.5|1|1|2026-03-01|
            :END:

            Final content.
        """.trimIndent()

        val result = HeadingTextUtils.stripReviewDataDrawers(body)

        println("Input:\n$body")
        println("Output:\n$result")

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain REVIEW_DATA drawer marker")
        assertTrue(result.contains(":LOGBOOK:"), "Should preserve LOGBOOK drawer")
        assertTrue(result.contains("CLOCK:"), "Should preserve LOGBOOK content")
    }

    // ============ replaceHeadingBody Tests ============

    @Test
    fun testReplaceHeadingBody_preservesLevel0Subheadings() {
        // Test for level-0 (file-level) content with sub-headings
        val content = """:PROPERTIES:
:ID: file-level-id
:END:
#+title: 2026-03-15

Main body content.

,* Sub heading 1
Content under sub heading 1.

,* Sub heading 2
Content under sub heading 2.
"""

        // Replace the body (everything after title, before sub-headings)
        val newBody = "Updated body content.\n\nMore details here."

        val result = HeadingTextUtils.replaceHeadingBody(
            content, 0, null, newBody
        )

        println("=== Result ===")
        // Verify title is preserved
        assertTrue(result.contains("#+title: 2026-03-15"), "Should preserve title")
    }

    @Test
    fun testReplaceHeadingBody_level0_withEmptyBody() {
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

Body content.

,* Sub heading
More content.
"""

        // Replace with empty body - sub-headings are part of the body and get removed
        val result = HeadingTextUtils.replaceHeadingBody(
            content, 0, null, ""
        )

        // With the new behavior, empty body removes all sub-headings since they're part of the body
        // Title and properties are preserved
        assertTrue(result.contains("#+title: Test"), "Should preserve title")
        assertTrue(result.contains(":PROPERTIES:"), "Should preserve properties")
        // Sub-headings are part of the body, so empty body means they're removed
        assertTrue(!result.contains("* Sub heading"), "Should not contain sub heading with empty body")
        assertTrue(!result.contains("More content."), "Should not contain sub heading content")
    }

    @Test
    fun testReplaceHeadingBody_level0_noSubheadings() {
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

Body content with no sub-headings.
"""

        val newBody = "Completely updated content."

        val result = HeadingTextUtils.replaceHeadingBody(
            content, 0, null, newBody
        )

        // Verify content was replaced
        assertTrue(result.contains("Completely updated content."), "Should contain new body")
        assertTrue(!result.contains("Body content with no sub-headings."), "Should not contain old body")
    }

    @Test
    fun testReplaceHeadingBody_regularHeading_stillWorks() {
        // Ensure regular heading (level > 0) still works after the level-0 changes
        val content = """* TODO Test Heading
:PROPERTIES:
:ID: test-id
:END:

Regular heading body content.

,** Sub heading
Sub heading content.
"""

        val newBody = "Updated regular body content."

        val result = HeadingTextUtils.replaceHeadingBody(
            content, 0, null, newBody
        )

        // Verify heading is preserved
        assertTrue(result.contains("* TODO Test Heading"), "Should preserve heading line")

        // Verify body was updated
        assertTrue(result.contains("Updated regular body content."), "Should contain new body")

        // Verify properties are preserved
        assertTrue(result.contains(":PROPERTIES:"), "Should preserve properties drawer")
        assertTrue(result.contains(":ID: test-id"), "Should preserve ID")

        // Sub-headings are part of the body — newBody is the complete replacement
        assertTrue(!result.contains("** Sub heading"), "Should NOT preserve sub heading — newBody is complete replacement")
        assertTrue(!result.contains("Sub heading content."), "Should NOT preserve sub heading content")
    }

    @Test
    fun testReplaceHeadingBody_level0_deepNestedChildren() {
        // Test deeply nested sub-headings (***)
        // With the new behavior, sub-headings are part of the body and get replaced
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Root

Main body.

,* Level 1
Content.

,** Level 2A
Content.

,*** Level 3
Deep content.

,** Level 2B
More content.
"""

        val newBody = "Updated main body."

        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(result.contains("#+title: Root"), "Should preserve title")
        // Sub-headings are part of the body and get replaced
        assertTrue(!result.contains("* Level 1"), "Should not contain old level 1")
        assertTrue(!result.contains("** Level 2A"), "Should not contain old level 2A")
        assertTrue(!result.contains("*** Level 3"), "Should not contain old level 3")
        assertTrue(!result.contains("** Level 2B"), "Should not contain old level 2B")
        assertTrue(result.contains("Updated main body."), "Should contain new body")
    }

    @Test
    fun testReplaceHeadingBody_level0_emptyBody_nestedChildren() {
        // Test empty body - sub-headings are part of the body and get removed
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Root

,* Level 1
Content.

,** Level 2
Deep content.
"""

        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "")

        assertTrue(result.contains("#+title: Root"), "Should preserve title")
        // Sub-headings are part of the body, so empty body means they're removed
        assertTrue(!result.contains("* Level 1"), "Should not contain level 1")
        assertTrue(!result.contains("** Level 2"), "Should not contain level 2")
    }

    @Test
    fun testReplaceHeadingBody_level0_whitespaceBody() {
        // Test with whitespace-only body content
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test



,* Sub heading
Content.
"""

        val newBody = "Actual body content."
        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(result.contains("Actual body content."), "Should contain new body")
        // Sub-heading was part of the original body and got replaced
        assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
    }

    @Test
    fun testReplaceHeadingBody_level0_withReviewData() {
        // Test level-0 nodes with REVIEW_DATA drawers in the body
        // With new behavior, sub-headings are part of the body
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

Body with flashcard.

:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:

,* Sub heading
Content.
"""

        val newBody = "Updated body without review data."

        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain review data")
        assertTrue(result.contains("Updated body without review data."), "Should contain new body")
        // Sub-heading was part of the body and got replaced
        assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
    }

    @Test
    fun testReplaceHeadingBody_level0_propertiesWithSpecialValues() {
        // Test properties with special values (aliases, geo coords, etc.)
        val content = """:PROPERTIES:
:ID: test-id
:PRIORITY: A
:ROAM_ALIASES: "alias1" "alias2"
:GEO_COORDS: "37.7749,-122.4194"
:END:
#+title: Test

Body content.

,* Sub heading
Content.
"""

        val newBody = "Updated body."
        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(result.contains(":ID: test-id"), "Should preserve ID")
        assertTrue(result.contains(":PRIORITY: A"), "Should preserve priority")
        assertTrue(result.contains(":ROAM_ALIASES:"), "Should preserve aliases")
        assertTrue(result.contains(":GEO_COORDS:"), "Should preserve geo coords")
    }

    @Test
    fun testReplaceHeadingBody_level0_noBlankLineBeforeSubheading() {
        // Test with sub-heading immediately after title (no blank line)
        // With the new behavior, sub-headings are part of the body and get replaced
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test
,* Sub heading
Content.
"""

        val newBody = "Updated body."
        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(result.contains("Updated body."), "Should contain new body")
        // Sub-heading was part of the body and got replaced
        assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
    }

    @Test
    fun testReplaceHeadingBody_level0_multipleReviewDataDrawers() {
        // Test multiple REVIEW_DATA drawers in level-0 content
        // With the new behavior, sub-headings are part of the body and get replaced
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

Body content.

:REVIEW_DATA:
|position|ease|box|interval|due|
|front|2.5|1|1|2026-03-01|
:END:

Some middle content.

:REVIEW_DATA:
|position|ease|box|interval|due|
|back|2.5|1|1|2026-03-01|
:END:

Final content.

,* Sub heading
Content.
"""

        val newBody = "New body."
        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        assertTrue(!result.contains(":REVIEW_DATA:"), "Should not contain any review data")
        assertTrue(result.contains("New body."), "Should contain new body")
        // Sub-heading was part of the body and got replaced
        assertTrue(!result.contains("* Sub heading"), "Should not contain original sub heading")
    }

    @Test
    fun testReplaceHeadingBody_level0_newBodyContainsSubheadings_noDuplication() {
        // This test ensures that when the new body contains sub-headings,
        // they are NOT duplicated from the original file's sub-headings
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

Original body.

,* Original Sub heading
Original content.

,* Another Original
More original.
"""

        // User edits and the new body includes the same sub-headings
        val newBody = """
Updated body.

,* Original Sub heading
Updated content.

,* Another Original
More updated content.
"""

        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        // Verify no duplication - each sub-heading should appear exactly once
        val originalSubHeadingCount = result.count { it == '*' && result.indexOf("\n* ") >= 0 }
        // The new body should replace the original content, not append to it
        assertTrue(result.contains("Updated body."), "Should contain new body")
        assertTrue(result.contains("Updated content."), "Should contain updated content")
        assertTrue(result.contains("More updated content."), "Should contain more updated content")
        // Original content should NOT be present (it was replaced)
        assertTrue(!result.contains("Original body."), "Should not contain original body")
        assertTrue(!result.contains("Original content."), "Should not contain original content")
    }

    @Test
    fun testReplaceHeadingBody_level0_bodyReplacesAll() {
        // The new body completely replaces the body section
        val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: Test

,* Many sub headings
,* In the original
,* File content

And some other content.
"""

        val newBody = "Completely new content with\n* One sub heading"

        val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)

        // Verify the entire body was replaced
        assertTrue(result.contains("#+title: Test"), "Should preserve title")
        assertTrue(result.contains("Completely new content with"), "Should contain new body")
        assertTrue(result.contains("* One sub heading"), "Should contain new sub heading")
        // Original content should NOT be present
        assertTrue(!result.contains("Many sub headings"), "Should not contain original sub heading")
        assertTrue(!result.contains("In the original"), "Should not contain original content")
        assertTrue(!result.contains("File content"), "Should not contain original content")
    }
}

HeadingTextUtilsPropertyTest

Uses property-based testing with OrgContentGenerators to verify replaceHeadingBody invariants: sub-heading preservation, metadata preservation, body replacement correctness, edge case handling (no properties drawer, special characters, unicode), idempotence, and whitespace tolerance.

kotlin#+name: editor-test-heading-text-utils-property:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/HeadingTextUtilsPropertyTest.kt
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.generators.OrgContentGenerators
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll

class HeadingTextUtilsPropertyTest : FunSpec({

    context("Property: Body replacement correctness") {
        test("newBody is the complete replacement - sub-headings come from newBody, not file") {
            forAll(OrgContentGenerators.orgDocument(5)) { content ->
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new body")
                // newBody has no asterisk lines, so output should not have sub-headings from file
                val outputSubheadings = result.lines().filter { it.startsWith("*") }
                outputSubheadings.size == 0
            }
        }

        test("deep nesting replaced by newBody") {
            forAll(Arb.int(1..10)) { level ->
                val stars = "*".repeat(level)
                val subStars = "*".repeat(level + 1)
                val content = """$stars Heading
:PROPERTIES:
:ID: test
:END:

Some body content here.

$subStars Sub Heading
:PROPERTIES:
:ID: sub-test
:END:

Sub content.
"""
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
                result.contains("Heading") && !result.contains("Sub Heading")
            }
        }
    }

    context("Property: Metadata preservation") {
        test(":PROPERTIES: drawer structure preserved") {
            forAll(
                OrgContentGenerators.validId,
                Arb.list(OrgContentGenerators.propertyValue, 1..10)
            ) { id, propValues ->
                val props = propValues.joinToString("\n") { ":VALUE: $it" }
                val content = """:PROPERTIES:
:ID: $id
$props
:END:
#+title: Test

Body.

,* Sub
Content.
"""
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
                result.contains(":PROPERTIES:") && result.contains(":END:")
            }
        }

        test("#+title: preserved through edits") {
            forAll(Arb.string(3..50)) { title ->
                val content = """:PROPERTIES:
:ID: test-id
:END:
#+title: $title

Body.

,* Sub
Content.
"""
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
                result.contains("#+title: $title")
            }
        }
    }

    context("Property: Body replacement correctness") {
        test("body replacement doesn't affect heading position") {
            forAll(OrgContentGenerators.orgDocument(3)) { content ->
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "replacement")
                // The title should still be near the start, not pushed down
                result.lines().indexOfFirst { it.startsWith("#+title:") } < 10
            }
        }

        test("empty body produces valid output") {
            forAll(OrgContentGenerators.orgDocument(5)) { content ->
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "")
                // Should not crash, should preserve structure
                result.lines().any { it.startsWith("#+title:") } == content.lines().any { it.startsWith("#+title:") }
            }
        }

        test("newBody with sub-headings appears in output") {
            forAll(OrgContentGenerators.orgDocument(3)) { content ->
                val newBody = "* New Sub\nContent."
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, newBody)
                result.contains("* New Sub")
            }
        }
    }

    context("Property: Input edge cases") {
        test("handles content without properties drawer") {
            forAll(OrgContentGenerators.orgDocument(3)) { content ->
                // Remove properties if present
                val cleanContent = content.replace(":PROPERTIES:", "").replace(":END:", "")
                val result = try {
                    HeadingTextUtils.replaceHeadingBody(cleanContent, 0, null, "new")
                    true
                } catch (e: Exception) {
                    false
                }
                result // Should not throw
            }
        }
    }

    context("Property: Idempotence") {
        test("replacing same body twice produces same result") {
            forAll(OrgContentGenerators.orgDocument(3)) { content ->
                val body = "idempotent body"
                val result1 = HeadingTextUtils.replaceHeadingBody(content, 0, null, body)
                val result2 = HeadingTextUtils.replaceHeadingBody(content, 0, null, body)
                result1 == result2
            }
        }
    }

    context("Property: Whitespace tolerance") {
        test("handles varying whitespace in properties") {
            forAll(
                Arb.string(1..20, Codepoint.alphanumeric()).map { ":$it: $it" },
                Arb.string(1..20, Codepoint.alphanumeric()).map { ":$it: $it" }
            ) { prop1, prop2 ->
                val content = """:PROPERTIES:
$prop1
$prop2
:END:
#+title: Test

Body

,* Sub
Content
"""
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
                result.contains(prop1) && result.contains(prop2)
            }
        }

        test("handles leading whitespace in content") {
            forAll(Arb.string(3..30)) { title ->
                val content = """
                    :PROPERTIES:
                    :ID: test-id
                    :END:
                    #+title: $title

                    Body

                    * Sub
                    Content
                """.trimIndent()
                val result = HeadingTextUtils.replaceHeadingBody(content, 0, null, "new")
                result.contains("#+title: $title")
            }
        }
    }
})

OrgDocumentEditorTest

Tests heading position finding logic via findHeadingPosition (regex-based ID matching to locate the correct heading line) and its integration with PropertyDrawerUtils.setProperty across simple, nested, file-keyword, UUID, and spacing scenarios. Also covers file-level property operations (setFileLevelProperty, removeFileLevelProperty) including drawer creation, property update/removal, and preservation of non-targeted properties.

kotlin#+name: editor-test-org-document-editor:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/OrgDocumentEditorTest.kt
package computer.whatthefuck.arcology.editor

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse

/**
 * Tests for OrgDocumentEditor's heading position finding logic,
 * its integration with PropertyDrawerUtils, and file-level property handling.
 */
class OrgDocumentEditorPositionTest {

    /**
     * Reproduce the regex-based heading position finding from OrgDocumentEditor.
     */
    private fun findHeadingPosition(content: String, nodeId: String, nodeLevel: Int): Int? {
        if (nodeLevel == 0) return 0

        val idPattern = Regex(":ID:\\s*${Regex.escape(nodeId)}\\s*$", RegexOption.MULTILINE)
        val idMatch = idPattern.find(content) ?: return null

        val beforeId = content.substring(0, idMatch.range.first)
        val headingPattern = Regex("^\\*+ .*$", RegexOption.MULTILINE)
        val lastHeadingMatch = headingPattern.findAll(beforeId).lastOrNull()

        return lastHeadingMatch?.range?.first ?: 0
    }

    // --- Heading-level position tests ---

    @Test
    fun testFindPositionAndSetPropertySimple() {
        val content = """* Test Heading
:PROPERTIES:
:ID: abc-123
:END:
Some body content."""

        val position = findHeadingPosition(content, "abc-123", 1)
        assertNotNull(position, "Should find heading position")
        assertEquals(0, position, "Heading should be at position 0")

        val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
        assertNotNull(result, "setProperty should succeed")
        assertTrue(result.contains(":PINNED: t"), "Should contain new property")
    }

    @Test
    fun testFindPositionAndSetPropertyWithFileKeywords() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:END:
#+title: My Note

,* Important Heading
:PROPERTIES:
:ID:       heading-id-123
:END:
Some content here."""

        val position = findHeadingPosition(content, "heading-id-123", 1)
        assertNotNull(position, "Should find heading position")

        val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
        assertNotNull(result, "setProperty should succeed for level-1 node with file keywords")
        assertTrue(result.contains(":PINNED: t"), "Should contain PINNED property")
        assertTrue(result.contains(":ID:       heading-id-123"), "Should preserve ID")
    }

    @Test
    fun testFindPositionAndSetPropertyNestedHeading() {
        val content = """* Parent Heading
:PROPERTIES:
:ID: parent-id
:END:
Parent content.

,** Child Heading
:PROPERTIES:
:ID: child-id
:END:
Child content."""

        val position = findHeadingPosition(content, "child-id", 2)
        assertNotNull(position, "Should find child heading position")

        val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
        assertNotNull(result, "setProperty should succeed for nested heading")
        assertTrue(result.contains(":PINNED: t"), "Should contain PINNED property")

        val childSection = result.substring(result.indexOf("** Child Heading"))
        assertTrue(childSection.contains(":PINNED: t"), "PINNED should be in child section")
    }

    @Test
    fun testFindPositionLevel0ReturnsZero() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:END:
#+title: My Note

,* Heading
:PROPERTIES:
:ID:       heading-id
:END:"""

        val position = findHeadingPosition(content, "file-level-id", 0)
        assertNotNull(position)
        assertEquals(0, position)
    }

    @Test
    fun testPropertyDrawerUtilsCannotHandleLevel0() {
        // Documents that PropertyDrawerUtils cannot handle file-level property drawers.
        // OrgDocumentEditor has separate logic for this.
        val content = """:PROPERTIES:
:ID:       file-level-id
:END:
#+title: My Note"""

        val result = PropertyDrawerUtils.setProperty(content, 0, "PINNED", "t")
        assertNull(result, "PropertyDrawerUtils returns null for level-0 (no OrgHeading at position 0)")
    }

    @Test
    fun testFindPositionWithUuidId() {
        val nodeId = "20240101T120000.123456"
        val content = """* Daily Note
:PROPERTIES:
:ID:       $nodeId
:END:
Today's notes."""

        val position = findHeadingPosition(content, nodeId, 1)
        assertNotNull(position)
        assertEquals(0, position)

        val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
        assertNotNull(result)
        assertTrue(result.contains(":PINNED: t"))
    }

    @Test
    fun testFindPositionWithSpacesAroundId() {
        val content = """* My Heading
:PROPERTIES:
:ID:       abc-123-def
:END:
Content."""

        val position = findHeadingPosition(content, "abc-123-def", 1)
        assertNotNull(position)
        assertEquals(0, position)

        val result = PropertyDrawerUtils.setProperty(content, position, "PINNED", "t")
        assertNotNull(result)
        assertTrue(result.contains(":PINNED: t"))
    }
}

/**
 * Tests for OrgDocumentEditor's file-level property operations.
 * These test the setFileLevelProperty/removeFileLevelProperty methods
 * which are used for level-0 nodes.
 */
class FileLevelPropertyTest {

    // We test the methods indirectly through a helper that mirrors the OrgDocumentEditor logic
    private fun setFileLevelProperty(content: String, key: String, value: String): String {
        val propertyLine = ":$key: $value"
        val trimmed = content.trimStart()

        if (trimmed.startsWith(":PROPERTIES:")) {
            val endIdx = content.indexOf(":END:")
            if (endIdx == -1) return content

            val drawerContent = content.substring(0, endIdx)
            val propertyPattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
            return if (propertyPattern.containsMatchIn(drawerContent)) {
                val updatePattern = Regex("^:${Regex.escape(key)}:.*$", RegexOption.MULTILINE)
                content.replace(updatePattern) { match ->
                    if (match.range.first < endIdx) propertyLine else match.value
                }
            } else {
                content.substring(0, endIdx) + "$propertyLine\n" + content.substring(endIdx)
            }
        } else {
            return ":PROPERTIES:\n$propertyLine\n:END:\n$content"
        }
    }

    private fun removeFileLevelProperty(content: String, key: String): String {
        val trimmed = content.trimStart()
        if (!trimmed.startsWith(":PROPERTIES:")) return content

        val endIdx = content.indexOf(":END:")
        if (endIdx == -1) return content

        val drawerContent = content.substring(0, endIdx)
        val propertyPattern = Regex("^:${Regex.escape(key)}:.*\\n?", RegexOption.MULTILINE)

        if (!propertyPattern.containsMatchIn(drawerContent)) return content

        val newDrawer = drawerContent.replace(propertyPattern, "")
        val remainingProps = newDrawer.lines().filter { line ->
            val t = line.trim()
            t.startsWith(":") && !t.startsWith(":PROPERTIES:") && t != ":"
        }
        if (remainingProps.isEmpty()) {
            val fullEndIdx = content.indexOf(":END:") + ":END:".length
            val afterDrawer = content.substring(fullEndIdx).trimStart('\n')
            return afterDrawer
        }

        return content.replace(propertyPattern) { match ->
            if (match.range.first < endIdx) "" else match.value
        }
    }

    @Test
    fun testSetPropertyInExistingFileDrawer() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:END:
#+title: My Note
"""

        val result = setFileLevelProperty(content, "PINNED", "t")
        assertTrue(result.contains(":PINNED: t"), "Should add PINNED property")
        assertTrue(result.contains(":ID:       file-level-id"), "Should preserve ID")
        assertTrue(result.contains(":PROPERTIES:"), "Should keep drawer")
        assertTrue(result.contains(":END:"), "Should keep END")
        assertTrue(result.contains("#+title: My Note"), "Should preserve title")
    }

    @Test
    fun testUpdateExistingPropertyInFileDrawer() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:PINNED: t
:END:
#+title: My Note
"""

        val result = setFileLevelProperty(content, "PINNED", "false")
        assertTrue(result.contains(":PINNED: false"), "Should update PINNED value")
        assertFalse(result.contains(":PINNED: t"), "Should not have old value")
    }

    @Test
    fun testSetPropertyCreatesDrawer() {
        val content = """#+title: My Note

,* First Heading
Some content."""

        val result = setFileLevelProperty(content, "PINNED", "t")
        assertTrue(result.startsWith(":PROPERTIES:"), "Should create drawer at start")
        assertTrue(result.contains(":PINNED: t"), "Should contain PINNED")
        assertTrue(result.contains(":END:"), "Should have END")
        assertTrue(result.contains("#+title: My Note"), "Should preserve original content")
    }

    @Test
    fun testRemovePropertyFromFileDrawer() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:PINNED: t
:END:
#+title: My Note
"""

        val result = removeFileLevelProperty(content, "PINNED")
        assertFalse(result.contains(":PINNED:"), "Should remove PINNED")
        assertTrue(result.contains(":ID:       file-level-id"), "Should preserve ID")
        assertTrue(result.contains(":PROPERTIES:"), "Drawer should remain (still has ID)")
    }

    @Test
    fun testRemoveOnlyNonIdPropertyKeepsDrawer() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:PINNED: t
:END:
#+title: My Note
"""

        val result = removeFileLevelProperty(content, "PINNED")
        assertTrue(result.contains(":PROPERTIES:"), "Drawer should remain for ID")
        assertTrue(result.contains(":ID:       file-level-id"), "ID should remain")
        assertFalse(result.contains(":PINNED:"), "PINNED should be removed")
    }

    @Test
    fun testRemoveNonexistentPropertyNoChange() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:END:
#+title: My Note
"""

        val result = removeFileLevelProperty(content, "PINNED")
        assertEquals(content, result, "Should return unchanged content")
    }

    @Test
    fun testRemovePropertyNoDrawerNoChange() {
        val content = """#+title: My Note

,* Heading
"""

        val result = removeFileLevelProperty(content, "PINNED")
        assertEquals(content, result, "Should return unchanged content")
    }

    @Test
    fun testSetPropertyPreservesDrawerOrder() {
        val content = """:PROPERTIES:
:ID:       file-level-id
:ROAM_ALIASES: "Alias One"
:END:
#+title: My Note
"""

        val result = setFileLevelProperty(content, "PINNED", "t")
        // PINNED should be added before :END:
        val pinnedIdx = result.indexOf(":PINNED: t")
        val endIdx = result.indexOf(":END:")
        assertTrue(pinnedIdx < endIdx, "PINNED should be before :END:")
        assertTrue(pinnedIdx > result.indexOf(":ID:"), "PINNED should be after :ID:")
    }
}

PositionBasedEditorTest

Tests the position-based editor methods (updateTodoStateByPosition, updatePlanningInfoByPosition) that work on headings without :ID: properties. Uses TestFileSystem and a no-op FileIndexingService to instantiate a real OrgDocumentEditor.

kotlin#+name: editor-test-position-based:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/PositionBasedEditorTest.kt
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileSystemInterface
import computer.whatthefuck.arcology.indexer.TestFileSystem
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class PositionBasedEditorTest {

    private fun createEditor(fs: FileSystemInterface): OrgDocumentEditor {
        val repo = mockk<RoamRepository>(relaxed = true)
        val indexer = mockk<FileIndexingService>(relaxed = true)
        return OrgDocumentEditor(fs, repo, indexer)
    }

    @Test
    fun `updateTodoStateByPosition sets TODO on heading without ID`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* Task without ID\nBody text.\n")
        val editor = createEditor(fs)

        val result = editor.updateTodoStateByPosition("/test.org", 0, "TODO")
        assertTrue(result is EditResult.Success, "Should succeed: ${(result as? EditResult.Error)?.message}")
        val content = fs.readFile("/test.org")
        assertTrue(content.contains("* TODO Task without ID"), "Should set TODO state")
    }

    @Test
    fun `updateTodoStateByPosition changes TODO to DONE`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Task\nBody.\n")
        val editor = createEditor(fs)

        val result = editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        assertTrue(result is EditResult.Success)
        val content = fs.readFile("/test.org")
        assertTrue(content.contains("* DONE Task"), "Should change TODO to DONE")
        assertFalse(content.contains("* TODO Task"), "Should not have old TODO state")
    }

    @Test
    fun `updateTodoStateByPosition appends LOGBOOK entry on state change`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Task\nBody.\n")
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        val content = fs.readFile("/test.org")
        assertTrue(content.contains(":LOGBOOK:"), "Should add LOGBOOK drawer")
        assertTrue(content.contains("State \"DONE\""), "Should log DONE state change")
    }

    @Test
    fun `updateTodoStateByPosition with repeater reschedules instead of marking DONE`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\nBody.\n")
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        val content = fs.readFile("/test.org")
        // Should flip back to TODO and advance the date
        assertTrue(content.contains("* TODO Daily task"), "Should flip back to TODO (repeater)")
        assertFalse(content.contains("* DONE Daily task"), "Should NOT stay DONE (repeater reschedule)")
        assertTrue(content.contains("<2026-07-02"), "Should advance SCHEDULED by 1 day")
    }

    @Test
    fun `updateTodoStateByPosition with repeater logs Emacs-style DONE from active state`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\nBody.\n")
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        val content = fs.readFile("/test.org")
        // Emacs logs the user's action as the toState: State "DONE" from "TODO"
        assertTrue(
            content.contains("State \"DONE\"  from \"TODO\""),
            "Repeater logbook entry should be Emacs-style (DONE from TODO), got:\n$content"
        )
        assertFalse(
            content.contains("State \"TODO\"  from \"DONE\""),
            "Repeater logbook entry should NOT be inverted, got:\n$content"
        )
    }

    @Test
    fun `updateTodoStateByPosition with REPEAT_TO_STATE uses that state`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Daily task\nSCHEDULED: <2026-07-01 Wed +1d>\n:PROPERTIES:\n:REPEAT_TO_STATE: NEXT\n:END:\nBody.\n")
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        val content = fs.readFile("/test.org")
        assertTrue(content.contains("* NEXT Daily task"), "Should flip to REPEAT_TO_STATE (NEXT)")
    }

    @Test
    fun `updateTodoStateByPosition no state change does not log`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Task\nBody.\n")
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "TODO")
        val content = fs.readFile("/test.org")
        assertFalse(content.contains(":LOGBOOK:"), "Should not log when state doesn't change")
    }

    @Test
    fun `updatePlanningInfoByPosition inserts SCHEDULED on heading without ID`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Task\nBody.\n")
        val editor = createEditor(fs)

        val result = editor.updatePlanningInfoByPosition("/test.org", 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 09:00>")
        assertTrue(result is EditResult.Success)
        val content = fs.readFile("/test.org")
        assertTrue(content.contains("SCHEDULED: <2026-08-15 Sat 09:00>"), "Should insert SCHEDULED line")
    }

    @Test
    fun `updatePlanningInfoByPosition removes SCHEDULED`() = runTest {
        val fs = TestFileSystem()
        fs.addFile("/test.org", "* TODO Task\nSCHEDULED: <2026-08-15 Sat 09:00>\nBody.\n")
        val editor = createEditor(fs)

        val result = editor.updatePlanningInfoByPosition("/test.org", 0, PlanningKind.SCHEDULED, null)
        assertTrue(result is EditResult.Success)
        val content = fs.readFile("/test.org")
        assertFalse(content.contains("SCHEDULED:"), "Should remove SCHEDULED line")
    }

    @Test
    fun `updateTodoStateByPosition returns error for missing file`() = runTest {
        val fs = TestFileSystem()
        val editor = createEditor(fs)

        val result = editor.updateTodoStateByPosition("/nonexistent.org", 0, "TODO")
        assertTrue(result is EditResult.Error, "Should return error for missing file")
    }

    @Test
    fun `updateTodoStateByPosition with repeater and priority cookie keeps SCHEDULED on its own line`() = runTest {
        val fs = TestFileSystem()
        fs.addFile(
            "/test.org",
            "* TODO [#A] Work on Arcology once a week\n" +
            "SCHEDULED: <2025-12-20 Sat ++1w>\n" +
            ":PROPERTIES:\n" +
            ":STYLE:    habit\n" +
            ":ID:       20210925T175056.966417\n" +
            ":END:\n" +
            ":LOGBOOK:\n" +
            ":END:\n" +
            "Body.\n"
        )
        val editor = createEditor(fs)

        editor.updateTodoStateByPosition("/test.org", 0, "DONE")
        val content = fs.readFile("/test.org")
        // Heading line must remain a single line; SCHEDULED must stay on its own line below it
        val lines = content.lines()
        val headingLine = lines.first { it.startsWith("*") }
        assertTrue(headingLine.startsWith("* NEXT [#A] Work on Arcology") || headingLine.startsWith("* TODO [#A] Work on Arcology"),
            "Heading should flip back to active state with priority preserved, got: $headingLine")
        assertFalse(headingLine.contains("SCHEDULED:"), "SCHEDULED must NOT be on the heading line, got: $headingLine")
        assertTrue(lines.any { it.trim().startsWith("SCHEDULED:") }, "SCHEDULED should be on its own line")
    }

    // --- Filetag (#+FILETAGS:) tests ---

    @Test
    fun `addFileTagToContent adds CLOSED to file with no existing filetags`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+title: My Project\n\n* Task 1\nBody.\n"
        val result = editor.addFileTagToContent(content, "CLOSED")
        assertTrue(result.contains("#+FILETAGS: :CLOSED:"))
        // Filetags line should be on its own line after title, not on the same line
        val lines = result.lines()
        val titleIdx = lines.indexOfFirst { it.startsWith("#+title:") }
        val filetagsIdx = lines.indexOfFirst { it.startsWith("#+FILETAGS:") }
        assertTrue(filetagsIdx > titleIdx, "FILETAGS should be after title")
        assertTrue(lines[filetagsIdx] == "#+FILETAGS: :CLOSED:", "FILETAGS line should be standalone, got: ${lines[filetagsIdx]}")
    }

    @Test
    fun `addFileTagToContent appends to existing filetags`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+title: My Project\n#+FILETAGS: :work:project:\n\n* Task 1\n"
        val result = editor.addFileTagToContent(content, "CLOSED")
        // Should NOT create a second FILETAGS line
        val filetagsCount = Regex("(?im)^#\\+FILETAGS:").findAll(result).count()
        assertEquals(1, filetagsCount, "Should have exactly one FILETAGS line, got $filetagsCount")
        assertTrue(result.contains(":work:project:CLOSED:"), "Should append CLOSED to existing tags, got: $result")
    }

    @Test
    fun `addFileTagToContent is no-op when tag already present`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+FILETAGS: :work:CLOSED:\n\n* Task\n"
        val result = editor.addFileTagToContent(content, "CLOSED")
        assertEquals(content, result, "Should be unchanged when tag already present")
    }

    @Test
    fun `addFileTagToContent inserts at top when no title`() {
        val editor = createEditor(TestFileSystem())
        val content = "* Task\nBody.\n"
        val result = editor.addFileTagToContent(content, "CLOSED")
        assertTrue(result.startsWith("#+FILETAGS: :CLOSED:"), "Should insert at top when no title, got: $result")
    }

    @Test
    fun `removeFileTagFromContent removes single tag from multi-tag line`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+FILETAGS: :work:CLOSED:project:\n\n* Task\n"
        val result = editor.removeFileTagFromContent(content, "CLOSED")
        val filetagsCount = Regex("(?im)^#\\+FILETAGS:").findAll(result).count()
        assertEquals(1, filetagsCount, "Should still have one FILETAGS line")
        assertTrue(result.contains(":work:project:"), "Should remove CLOSED, keep others, got: $result")
        assertFalse(result.contains("CLOSED"), "Should not contain CLOSED, got: $result")
    }

    @Test
    fun `removeFileTagFromContent removes whole line when last tag removed`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+title: My Project\n#+FILETAGS: :CLOSED:\n\n* Task\n"
        val result = editor.removeFileTagFromContent(content, "CLOSED")
        assertFalse(result.contains("FILETAGS"), "Should remove FILETAGS line entirely, got: $result")
        assertTrue(result.contains("#+title: My Project"), "Should preserve title")
    }

    @Test
    fun `removeFileTagFromContent is no-op when tag not present`() {
        val editor = createEditor(TestFileSystem())
        val content = "#+FILETAGS: :work:project:\n\n* Task\n"
        val result = editor.removeFileTagFromContent(content, "CLOSED")
        assertEquals(content, result, "Should be unchanged when tag not present")
    }
}

PropertyDrawerUtilsTest

Tests setProperty and removeProperty on heading-level property drawers: adding to existing drawers, updating values, preserving unrelated properties, creating new drawers (plain, after TODO, after tags), nested heading isolation, and invalid position handling. removeProperty tests include drawer removal when last property is deleted, no-op for nonexistent keys, and nested scope isolation.

kotlin#+name: editor-test-property-drawer-utils:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/PropertyDrawerUtilsTest.kt
package computer.whatthefuck.arcology.editor

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class PropertyDrawerUtilsTest {

    @Test
    fun testAddPropertyToExistingDrawer() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:ID: test-id-123
            |:END:
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "37.7749,-122.4194"
        )

        assertNotNull(result)
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":ID: test-id-123"))
        assertTrue(result.contains(":GEO_COORDS: 37.7749,-122.4194"))
        assertTrue(result.contains(":END:"))
        assertTrue(result.contains("Some body content."))
    }

    @Test
    fun testUpdateExistingProperty() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:ID: test-id-123
            |:GEO_COORDS: 0.0,0.0
            |:END:
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "37.7749,-122.4194"
        )

        assertNotNull(result)
        assertTrue(result.contains(":GEO_COORDS: 37.7749,-122.4194"))
        assertFalse(result.contains(":GEO_COORDS: 0.0,0.0"))
    }

    @Test
    fun testPreserveOtherProperties() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:ID: test-id-123
            |:CATEGORY: notes
            |:END:
            |Body text here.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "40.7128,-74.0060"
        )

        assertNotNull(result)
        assertTrue(result.contains(":ID: test-id-123"))
        assertTrue(result.contains(":CATEGORY: notes"))
        assertTrue(result.contains(":GEO_COORDS: 40.7128,-74.0060"))
    }

    @Test
    fun testCreateNewPropertiesDrawer() {
        val content = """
            |* Test Heading
            |Some body content without properties.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "51.5074,-0.1278"
        )

        assertNotNull(result)
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":GEO_COORDS: 51.5074,-0.1278"))
        assertTrue(result.contains(":END:"))
        assertTrue(result.contains("Some body content"))
    }

    @Test
    fun testCreateDrawerAfterTodoState() {
        val content = """
            |* TODO Test Task
            |Body content here.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "48.8566,2.3522"
        )

        assertNotNull(result)
        assertTrue(result.contains("* TODO Test Task"))
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":GEO_COORDS: 48.8566,2.3522"))
        assertTrue(result.contains(":END:"))
    }

    @Test
    fun testCreateDrawerAfterHeadingWithTags() {
        val content = """
            |* Heading with tags :tag1:tag2:
            |Body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.setProperty(
            content, headingPosition, "GEO_COORDS", "35.6762,139.6503"
        )

        assertNotNull(result)
        assertTrue(result.contains("* Heading with tags :tag1:tag2:"))
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":GEO_COORDS: 35.6762,139.6503"))
    }

    @Test
    fun testNestedHeading() {
        val content = """
            |* Parent Heading
            |:PROPERTIES:
            |:ID: parent-id
            |:END:
            |Parent content.
            |
            |** Child Heading
            |:PROPERTIES:
            |:ID: child-id
            |:END:
            |Child content.
        """.trimMargin()

        // Find position of child heading
        val childPosition = content.indexOf("** Child Heading")
        val result = PropertyDrawerUtils.setProperty(
            content, childPosition, "GEO_COORDS", "52.5200,13.4050"
        )

        assertNotNull(result)
        // Parent should be unchanged - check it doesn't have GEO_COORDS after parent-id
        assertTrue(result.contains(":ID: parent-id"))
        // Child should have new property
        assertTrue(result.contains(":ID: child-id"))
        assertTrue(result.contains(":GEO_COORDS: 52.5200,13.4050"))
    }

    @Test
    fun testInvalidHeadingPositionReturnsNull() {
        val content = """
            |* Test Heading
            |Body content.
        """.trimMargin()

        val result = PropertyDrawerUtils.setProperty(
            content, 9999, "GEO_COORDS", "0.0,0.0"
        )

        assertNull(result)
    }

    @Test
    fun testPositionInBodyReturnsNull() {
        val content = """
            |* Test Heading
            |Body content here.
        """.trimMargin()

        // Position in the middle of body content
        val bodyPosition = content.indexOf("Body content")
        val result = PropertyDrawerUtils.setProperty(
            content, bodyPosition, "GEO_COORDS", "0.0,0.0"
        )

        assertNull(result)
    }

    // Tests for removeProperty

    @Test
    fun testRemovePropertyFromDrawer() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:ID: test-id-123
            |:PINNED: t
            |:END:
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.removeProperty(
            content, headingPosition, "PINNED"
        )

        assertNotNull(result)
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":ID: test-id-123"))
        assertFalse(result.contains(":PINNED:"))
        assertTrue(result.contains(":END:"))
        assertTrue(result.contains("Some body content."))
    }

    @Test
    fun testRemoveOnlyPropertyRemovesDrawer() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:PINNED: t
            |:END:
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.removeProperty(
            content, headingPosition, "PINNED"
        )

        assertNotNull(result)
        assertFalse(result.contains(":PROPERTIES:"))
        assertFalse(result.contains(":PINNED:"))
        assertFalse(result.contains(":END:"))
        assertTrue(result.contains("* Test Heading"))
        assertTrue(result.contains("Some body content."))
    }

    @Test
    fun testRemoveNonExistentPropertyReturnsUnchanged() {
        val content = """
            |* Test Heading
            |:PROPERTIES:
            |:ID: test-id-123
            |:END:
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.removeProperty(
            content, headingPosition, "PINNED"
        )

        assertNotNull(result)
        assertEquals(content, result)
    }

    @Test
    fun testRemovePropertyNoDrawerReturnsUnchanged() {
        val content = """
            |* Test Heading
            |Some body content.
        """.trimMargin()

        val headingPosition = 0
        val result = PropertyDrawerUtils.removeProperty(
            content, headingPosition, "PINNED"
        )

        assertNotNull(result)
        assertEquals(content, result)
    }

    @Test
    fun testRemovePropertyFromNestedHeading() {
        val content = """
            |* Parent Heading
            |:PROPERTIES:
            |:ID: parent-id
            |:PINNED: t
            |:END:
            |Parent content.
            |
            |** Child Heading
            |:PROPERTIES:
            |:ID: child-id
            |:PINNED: t
            |:END:
            |Child content.
        """.trimMargin()

        // Find position of child heading
        val childPosition = content.indexOf("** Child Heading")
        val result = PropertyDrawerUtils.removeProperty(
            content, childPosition, "PINNED"
        )

        assertNotNull(result)
        // Parent should still have PINNED
        val parentSection = result.substring(0, result.indexOf("** Child Heading"))
        assertTrue(parentSection.contains(":PINNED: t"))
        // Child should not have PINNED
        val childSection = result.substring(result.indexOf("** Child Heading"))
        assertFalse(childSection.contains(":PINNED:"))
    }
}

ReviewDataDrawerUtilsTest

Tests REVIEW_DATA drawer format/create/parse utilities: formatReviewDataRow with all/null fields, createDrawer output format, setFileLevelReviewData (create new, position after PROPERTIES, update existing, update existing position), setHeadingLevelReviewData (create after PROPERTIES, create without properties, update existing, scope isolation), and multi-position integration for cloze and double-sided cards.

kotlin#+name: editor-test-review-data-drawer-utils:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/ReviewDataDrawerUtilsTest.kt
package computer.whatthefuck.arcology.editor

import computer.whatthefuck.arcology.flashcard.ReviewData
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant

/**
 * Tests for ReviewDataDrawerUtils - REVIEW_DATA drawer manipulation.
 */
class ReviewDataDrawerUtilsTest {

    // === formatReviewDataRow tests ===

    @Test
    fun testFormatReviewDataRow_withAllFields() {
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 3.5,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val row = ReviewDataDrawerUtils.formatReviewDataRow(reviewData)
        assertTrue(row.contains("front"))
        assertTrue(row.contains("2.5"))
        assertTrue(row.contains("1"))
        assertTrue(row.contains("3.5"))
        assertTrue(row.contains("2025-01-15T10:00:00Z"))
    }

    @Test
    fun testFormatReviewDataRow_withNullFields() {
        val reviewData = ReviewData(
            positionName = "back",
            easeFactor = null,
            box = null,
            intervalDays = null,
            dueDate = null
        )
        val row = ReviewDataDrawerUtils.formatReviewDataRow(reviewData)
        assertTrue(row.contains("back"))
    }

    // === createDrawer tests ===

    @Test
    fun testCreateDrawer_createsCorrectFormat() {
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val drawer = ReviewDataDrawerUtils.createDrawer(reviewData)

        assertTrue(drawer.startsWith(":REVIEW_DATA:"))
        assertTrue(drawer.contains("| position | ease | box | interval | due |"))
        assertTrue(drawer.contains("|----------+------+-----+----------+------|"))
        assertTrue(drawer.contains("| front | 2.5 | 1 | 1.00 | 2025-01-15T10:00:00Z |"))
        assertTrue(drawer.trimEnd().endsWith(":END:"))
    }

    // === setFileLevelReviewData tests ===

    @Test
    fun testSetFileLevelReviewData_createsNewDrawer() {
        val content = """#+title: My Note

,* Some Heading
Content here.
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)

        assertTrue(result.startsWith(":REVIEW_DATA:"))
        assertTrue(result.contains(":END:"))
        assertTrue(result.contains("front"))
        assertTrue(result.contains("#+title: My Note"))
    }

    @Test
    fun testSetFileLevelReviewData_createsDrawerAfterProperties() {
        val content = """:PROPERTIES:
:ID: file-id-123
:END:
#+title: My Note

,* Some Heading
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)

        // REVIEW_DATA drawer should come after PROPERTIES
        val propsEndIdx = result.indexOf(":END:")
        val reviewDataStartIdx = result.indexOf(":REVIEW_DATA:")
        assertTrue(reviewDataStartIdx > propsEndIdx, "REVIEW_DATA should come after PROPERTIES drawer")
        assertTrue(result.contains(":ID: file-id-123"))
    }

    @Test
    fun testSetFileLevelReviewData_updatesExistingDrawer() {
        val content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| back | 2.0 | 2 | 5.0 | 2025-01-10T00:00:00Z |
:END:

#+title: My Note
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)

        // Should have both positions now
        assertTrue(result.contains("front"))
        assertTrue(result.contains("back"))
        assertTrue(result.contains("2.5"))  // new ease
    }

    @Test
    fun testSetFileLevelReviewData_updatesExistingPosition() {
        val content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| front | 2.0 | 2 | 5.0 | 2025-01-10T00:00:00Z |
:END:

#+title: My Note
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 3,
            intervalDays = 10.0,
            dueDate = Instant.parse("2025-01-20T12:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData)

        // Should update existing position
        assertTrue(result.contains("front"))
        assertTrue(result.contains("2.5"))
        assertTrue(result.contains("3"))
        assertTrue(result.contains("10.00"))
        assertTrue(result.contains("2025-01-20T12:00:00Z"))
        // Should NOT have old values
        val frontLineIdx = result.indexOf("| front")
        val frontLineEnd = result.indexOf('\n', frontLineIdx)
        val frontLine = result.substring(frontLineIdx, frontLineEnd)
        assertTrue(!frontLine.contains("2.0"))  // old ease factor
    }

    // === setHeadingLevelReviewData tests ===

    @Test
    fun testSetHeadingLevelReviewData_createsNewDrawerAfterProperties() {
        val content = """* Test Heading
:PROPERTIES:
:ID: heading-id-123
:END:
Some body content.
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)

        assertNotNull(result)
        // REVIEW_DATA should come after :END: of PROPERTIES
        val propsEndIdx = result.indexOf(":END:")
        val reviewDataStartIdx = result.indexOf(":REVIEW_DATA:")
        assertTrue(reviewDataStartIdx > propsEndIdx, "REVIEW_DATA should come after PROPERTIES")
        assertTrue(result.contains("front"))
        assertTrue(result.contains("2.5"))
    }

    @Test
    fun testSetHeadingLevelReviewData_createsDrawerWhenNoProperties() {
        val content = """* Test Heading
Some body content.
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)

        assertNotNull(result)
        assertTrue(result.contains(":REVIEW_DATA:"))
        assertTrue(result.contains("front"))
        assertTrue(result.contains(":END:"))
    }

    @Test
    fun testSetHeadingLevelReviewData_updatesExistingDrawer() {
        val content = """* Test Heading
:PROPERTIES:
:ID: heading-id-123
:END:
:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
| back | 2.0 | 1 | 3.0 | 2025-01-10T00:00:00Z |
:END:
Some body content.
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)

        assertNotNull(result)
        // Should have both positions
        assertTrue(result.contains("front"))
        assertTrue(result.contains("back"))
        assertTrue(result.contains("2.5"))  // new position ease
    }

    @Test
    fun testSetHeadingLevelReviewData_doesNotAffectOtherHeadings() {
        val content = """* First Heading
:PROPERTIES:
:ID: first-id
:END:
First content.

,* Second Heading
:PROPERTIES:
:ID: second-id
:END:
Second content.
"""
        val reviewData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        val result = ReviewDataDrawerUtils.setHeadingLevelReviewData(content, 0, reviewData)

        assertNotNull(result)
        // REVIEW_DATA should only be in the first heading
        val firstReviewDataIdx = result.indexOf(":REVIEW_DATA:")
        val secondHeadingIdx = result.indexOf("* Second Heading")
        assertTrue(firstReviewDataIdx < secondHeadingIdx, "REVIEW_DATA should be in first heading")
        // Verify only one REVIEW_DATA drawer
        val reviewDataCount = result.split(":REVIEW_DATA:").size - 1
        assertEquals(1, reviewDataCount, "Should only have one REVIEW_DATA drawer")
    }

    // === Integration tests with multiple positions ===

    @Test
    fun testMultiplePositions_clozeCard() {
        var content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
:END:
"""
        // Add first position
        val reviewData0 = ReviewData(
            positionName = "0",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        content = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData0)

        // Add second position
        val reviewData1 = ReviewData(
            positionName = "1",
            easeFactor = 2.6,
            box = 2,
            intervalDays = 3.0,
            dueDate = Instant.parse("2025-01-17T10:00:00Z")
        )
        content = ReviewDataDrawerUtils.setFileLevelReviewData(content, reviewData1)

        // Verify both positions exist
        assertTrue(content.contains("| 0 |"))
        assertTrue(content.contains("| 1 |"))
        assertTrue(content.contains("2.5"))
        assertTrue(content.contains("2.6"))
    }

    @Test
    fun testMultiplePositions_doubleSidedCard() {
        var content = """:REVIEW_DATA:
| position | ease | box | interval | due |
|----------+------+-----+----------+------|
:END:
"""
        // Add front
        val frontData = ReviewData(
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.parse("2025-01-15T10:00:00Z")
        )
        content = ReviewDataDrawerUtils.setFileLevelReviewData(content, frontData)

        // Add back
        val backData = ReviewData(
            positionName = "back",
            easeFactor = 2.8,
            box = 2,
            intervalDays = 5.0,
            dueDate = Instant.parse("2025-01-20T10:00:00Z")
        )
        content = ReviewDataDrawerUtils.setFileLevelReviewData(content, backData)

        // Verify both positions
        assertTrue(content.contains("| front |"))
        assertTrue(content.contains("| back |"))
        assertTrue(content.contains("2.5"))
        assertTrue(content.contains("2.8"))
    }
}

PlanningInfoUtilsTest

Tests PlanningInfoUtils — planning line insert/replace/remove (updatePlanningLine), reading (readPlanningLine), and repeater rescheduling (computeReschedule). Covers all three repeater types (EXACT +, CUMULATE ++, CATCH_UP .+), REPEAT_TO_STATE property override, precedence of SCHEDULED over DEADLINE, weekday/time preservation, and level-0 rejection.

kotlin#+name: editor-test-planning-info-utils:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/editor/PlanningInfoUtilsTest.kt
package computer.whatthefuck.arcology.editor

import kotlinx.datetime.LocalDate
import xyz.lepisma.orgmode.Repeater
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class PlanningInfoUtilsTest {

    // ============ updatePlanningLine tests ============

    @Test
    fun testInsertScheduledBeforePropertiesDrawer() {
        val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body text.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 14:00>")
        assertNotNull(result)
        val scheduledIdx = result.indexOf("SCHEDULED:")
        val propsIdx = result.indexOf(":PROPERTIES:")
        assertTrue(scheduledIdx > 0, "SCHEDULED should be present")
        assertTrue(scheduledIdx < propsIdx, "SCHEDULED should come before :PROPERTIES:")
        assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat 14:00>"))
    }

    @Test
    fun testInsertScheduledNoPropertiesDrawer() {
        val content = """* TODO Task
Body text.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat>")
        assertNotNull(result)
        assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat>"))
        assertTrue(result.contains("Body text."))
    }

    @Test
    fun testReplaceExistingScheduled() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat>")
        assertNotNull(result)
        assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat>"))
        assertFalse(result.contains("SCHEDULED: <2026-07-01"))
    }

    @Test
    fun testReplaceDeadlineLeavingScheduledIntact() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
DEADLINE: <2026-07-15 Tue>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, "<2026-08-20 Fri>")
        assertNotNull(result)
        assertTrue(result.contains("SCHEDULED: <2026-07-01 Wed>"))
        assertTrue(result.contains("DEADLINE: <2026-08-20 Fri>"))
        assertFalse(result.contains("DEADLINE: <2026-07-15"))
    }

    @Test
    fun testRemoveScheduled() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, null)
        assertNotNull(result)
        assertFalse(result.contains("SCHEDULED:"))
        assertTrue(result.contains(":PROPERTIES:"))
        assertTrue(result.contains(":ID: task-id"))
    }

    @Test
    fun testRemoveNonExistentPlanningLineIsNoOp() {
        val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, null)
        assertNotNull(result)
        assertEquals(content, result)
    }

    @Test
    fun testInsertOnHeadingWithTodoAndTags() {
        val content = """* TODO Task :work:urgent:
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.DEADLINE, "<2026-08-15 Sat>")
        assertNotNull(result)
        assertTrue(result.contains("* TODO Task :work:urgent:"))
        assertTrue(result.contains("DEADLINE: <2026-08-15 Sat>"))
    }

    @Test
    fun testInactiveTimestampPreserved() {
        val content = """* Task
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.CLOSED, "[2026-08-15 Sat]")
        assertNotNull(result)
        assertTrue(result.contains("CLOSED: [2026-08-15 Sat]"))
    }

    @Test
    fun testRepeaterPreservedInString() {
        val content = """* TODO Task
Body.
"""
        val result = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat +1w>")
        assertNotNull(result)
        assertTrue(result.contains("SCHEDULED: <2026-08-15 Sat +1w>"))
    }

    @Test
    fun testRoundTripReadAfterWrite() {
        val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val written = PlanningInfoUtils.updatePlanningLine(content, 0, PlanningKind.SCHEDULED, "<2026-08-15 Sat 14:00 +1d>")
        assertNotNull(written)
        val readBack = PlanningInfoUtils.readPlanningLine(written, 0, PlanningKind.SCHEDULED)
        assertNotNull(readBack)
        assertTrue(readBack.contains("SCHEDULED:"))
        assertTrue(readBack.contains("<2026-08-15 Sat 14:00 +1d>"))
    }

    @Test
    fun testReadNonExistentPlanningLineReturnsNull() {
        val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val result = PlanningInfoUtils.readPlanningLine(content, 0, PlanningKind.SCHEDULED)
        assertNull(result)
    }

    // ============ computeReschedule tests ============

    @Test
    fun testRescheduleExactRepeaterShiftsFromOriginal() {
        // Original date 2026-07-01, today 2026-08-01, +1d EXACT
        // Should shift to 2026-07-02 (backlog allowed)
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 8, 1)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2026-07-02"), "EXACT should shift from original date")
        assertEquals("TODO", result.newTodoState)
        assertEquals(PlanningKind.SCHEDULED, result.rescheduledKind)
    }

    @Test
    fun testRescheduleCumulateRepeaterShiftsFromToday() {
        // Original date 2026-07-01, today 2026-08-01, ++1d CUMULATE
        // Should shift to 2026-08-02 (from today)
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed ++1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 8, 1)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        println("DEBUG CUMULATE: result=$result")
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2026-08-02"), "CUMULATE should shift from today")
    }

    @Test
    fun testRescheduleCatchUpRepeaterSkipsPast() {
        // Original date 2026-07-01, today 2026-08-01, .+1d CATCH_UP
        // Should keep adding 1 day until >= 2026-08-01
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed .+1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 8, 1)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        // The shifted date should be >= today (2026-08-01)
        assertTrue(result.newContent.contains("<2026-08-01") || result.newContent.contains("<2026-08-02"),
            "CATCH_UP should land on or after today")
    }

    @Test
    fun testRescheduleMonthlyRepeater() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1m>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2026-08-01"), "Should shift by 1 month")
    }

    @Test
    fun testRescheduleNoRepeaterReturnsNull() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNull(result)
    }

    @Test
    fun testRescheduleRepeatToStateOverridesDefault() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:REPEAT_TO_STATE: NEXT
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertEquals("NEXT", result.newTodoState, "REPEAT_TO_STATE should override default TODO")
    }

    @Test
    fun testRescheduleNoRepeatToStateDefaultsToTodo() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertEquals("TODO", result.newTodoState)
    }

    @Test
    fun testRescheduleDefaultRepeatToStateParameterOverridesTodo() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today, defaultRepeatToState = "NEXT")
        assertNotNull(result)
        assertEquals("NEXT", result.newTodoState, "defaultRepeatToState param should override the TODO default")
    }

    @Test
    fun testRescheduleRepeatToStatePropertyOverridesDefaultRepeatToStateParam() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:REPEAT_TO_STATE: WAITING
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today, defaultRepeatToState = "NEXT")
        assertNotNull(result)
        assertEquals("WAITING", result.newTodoState, "REPEAT_TO_STATE property should override the defaultRepeatToState param")
    }

    @Test
    fun testRescheduleScheduledPrecedesDeadline() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
DEADLINE: <2026-07-10 Fri +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertEquals(PlanningKind.SCHEDULED, result.rescheduledKind, "SCHEDULED should take precedence")
    }

    @Test
    fun testRescheduleDeadlineOnlyWithRepeater() {
        val content = """* TODO Task
DEADLINE: <2026-07-10 Fri +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertEquals(PlanningKind.DEADLINE, result.rescheduledKind)
        assertTrue(result.newContent.contains("<2026-07-17"), "Should shift deadline by 1 week")
    }

    @Test
    fun testRescheduleNoPlanningInfoReturnsNull() {
        val content = """* TODO Task
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNull(result)
    }

    @Test
    fun testReschedulePreservesTimeOfDay() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed 09:00 +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("09:00"), "Time-of-day should be preserved")
    }

    @Test
    fun testRescheduleWeeklyRepeater() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2026-07-08"), "Should shift by 1 week")
    }

    @Test
    fun testRescheduleYearlyRepeater() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1y>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2027-07-01"), "Should shift by 1 year")
    }

    @Test
    fun testRescheduleCatchUpWeeklySkipsPast() {
        // Original 2026-07-01, today 2026-08-01, .+1w CATCH_UP
        // Should land on or after 2026-08-01
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed .+1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 8, 1)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        // Should have shifted forward by weeks until >= 2026-08-01
        assertFalse(result.newContent.contains("<2026-07-0"), "Should not be in July")
    }

    @Test
    fun testReschedulePreservesRepeaterInNewTimestamp() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed +1d>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 7, 15)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("+1d>"), "Repeater should be preserved in new timestamp")
    }

    @Test
    fun testRescheduleCumulateWeeklyFromToday() {
        val content = """* TODO Task
SCHEDULED: <2026-07-01 Wed ++1w>
:PROPERTIES:
:ID: task-id
:END:
Body.
"""
        val today = LocalDate(2026, 8, 1)
        val result = PlanningInfoUtils.computeReschedule(content, 0, today)
        assertNotNull(result)
        assertTrue(result.newContent.contains("<2026-08-08"), "CUMULATE weekly should be today + 1 week")
    }

    @Test
    fun testAdvanceDateExactDoesNotJumpToToday() {
        val original = LocalDate(2026, 7, 1)
        val today = LocalDate(2026, 12, 1)
        val repeater = Repeater.parse("+1d")!!
        val result = PlanningInfoUtils.advanceDate(original, repeater, today)
        assertEquals(LocalDate(2026, 7, 2), result, "EXACT shifts from original, not today")
    }

    @Test
    fun testAdvanceDateCumulateJumpsFromToday() {
        val original = LocalDate(2026, 7, 1)
        val today = LocalDate(2026, 12, 1)
        val repeater = Repeater.parse("++1m")!!
        val result = PlanningInfoUtils.advanceDate(original, repeater, today)
        assertEquals(LocalDate(2027, 1, 1), result, "CUMULATE shifts from today")
    }

    @Test
    fun testFormatTimestampWithTimeAndRepeater() {
        val ts = PlanningInfoUtils.formatTimestamp(
            date = LocalDate(2026, 8, 15),
            time = kotlinx.datetime.LocalTime(14, 30),
            isActive = true,
            repeater = "+1w",
            showWeekday = true
        )
        // 2026-08-15 is a Saturday
        assertEquals("<2026-08-15 Sat 14:30 +1w>", ts)
    }

    @Test
    fun testFormatTimestampInactive() {
        val ts = PlanningInfoUtils.formatTimestamp(
            date = LocalDate(2026, 8, 15),
            time = null,
            isActive = false,
            repeater = null,
            showWeekday = true
        )
        assertEquals("[2026-08-15 Sat]", ts)
    }
}

Related Modules

  • indexer.org — File indexing service (used for re-indexing after edits)

  • models.org — Database repository and domain models

  • parser.org — Org-mode AST parser (used by PropertyDrawerUtils and NodeContentParser)

  • indexer-platform.org — File system abstraction (FileSystemInterface)