Arcology Engine

Arroyo Generators — DB Schema, Repository, CLI, and Tests

Contents

The indexer captures ARROYO_* file and heading properties into denormalized SQLDelight tables. A CLI exposes queries directly to stdout — callable from terminal or Emacs. This document covers the schema, repository, topological sort utility, CLI commands, and tests.

Data Layer

Repository Interface

Arroyo gets its own repository interface and implementation, following the QuizRepository pattern. It uses database.arroyoQueries (generated from Arroyo.sq) and is wired alongside the other repositories in the factory.

kotlin#+name: arroyo-repo-interface
interface ArroyoRepository {
    // Module queries (all backed by arroyo_keywords)
    suspend fun getNixosModules(role: String? = null): List<String>
    suspend fun getNixosModulesMeta(role: String? = null): List<ModuleMeta>
    suspend fun getHomeManagerModules(role: String? = null): List<String>
    suspend fun getHomeManagerModulesMeta(role: String? = null): List<ModuleMeta>
    suspend fun getEmacsModules(): List<String>
    suspend fun getEmacsModulesMeta(): List<EmacsModuleMeta>
    suspend fun getEmacsEpkgs(): List<String>
    suspend fun getSystemOverlays(role: String? = null): List<String>
    suspend fun getInputs(role: String? = null): List<String>
    suspend fun getOutputs(): List<String>
    suspend fun getAgeRecipients(role: String? = null): List<AgeRecipient>

    // Generator roles
    suspend fun getGeneratorRoles(): List<String>
    suspend fun insertGeneratorRole(file: String, nodeId: String?, role: String)
    suspend fun deleteGeneratorRolesByFile(file: String)

    // Arroyo keywords (unified store for all ARROYO_* values)
    suspend fun getArroyoKeywords(kw: String): List<Pair<String, String>>
    suspend fun getArroyoKeywordsWithNodeId(kw: String): List<Triple<String, String?, String>>
    suspend fun insertArroyoKeyword(file: String, kw: String, value: String, nodeId: String? = null)
    suspend fun deleteArroyoKeywordsByFile(file: String)

    // File metadata
    suspend fun getFileTitle(filePath: String): String?

    // Flake generation
    suspend fun getFlakeInputs(orgDir: String): List<FlakeInput>
    suspend fun getNixosRoleModules(role: String, orgDir: String): List<FlakeModuleRef>
    suspend fun getHomeRoleModules(role: String, orgDir: String): List<FlakeModuleRef>

    // Bulk delete for reindexing
    suspend fun deleteAllByFile(file: String)

    // Node ancestor closure table (for property inheritance)
    suspend fun insertNodeAncestor(nodeId: String, ancestorId: String)
    suspend fun deleteNodeAncestorsByFile(file: String)
}

Unified Module Schema

All ARROYO_* module declarations (ARROYO_NIXOS_MODULE, ARROYO_HOME_MODULE, ARROYO_EMACS_MODULE, ARROYO_HOME_EPKGS, etc.) are stored in a single arroyo_keywords table. Role inclusion is computed at query time by joining against arroyo_generator_roles; exclusion is computed by self-joining arroyo_keywords for ARROYO_SYSTEM_EXCLUDE / ARROYO_EXCLUDE_ROLE entries. This eliminates the per-module-type table explosion — adding a new module type (nixpkgs overlays, version pins) requires only a new SQL query, not new tables.

Role semantics: if a heading has no ARROYO_SYSTEM_ROLE / ARROYO_ROLE entries in arroyo_generator_roles, its modules apply to ALL roles. If roles are specified, only those roles match. Excluded roles are stored as arroyo_keywords rows and filtered out at query time. Roles and exclusions inherit through the node_ancestors closure table — a file-level role applies to all headings in the file; a heading-scoped role applies to that heading and its children.

Schema

sql#+name: arroyo-unified-ddl
-- Arroyo: System roles (from ARROYO_SYSTEM_ROLE / ARROYO_ROLE)
-- PK includes node_id so the same role can appear under multiple headings in one file.
-- node_id is populated for heading-scoped roles, null for file-level.
CREATE TABLE IF NOT EXISTS arroyo_generator_roles (
    file TEXT NOT NULL,
    node_id TEXT,
    role TEXT NOT NULL,
    PRIMARY KEY (file, node_id, role)
);

-- Arroyo: Unified keyword store for all ARROYO_* values
-- PK is (file, kw, value) — a file can have multiple values for the same keyword
-- (e.g. multiple ARROYO_NIXOS_MODULE entries, multiple ARROYO_SYSTEM_EXCLUDE entries).
-- node_id is populated for heading-scoped keywords, null for file-level.
CREATE TABLE IF NOT EXISTS arroyo_keywords (
    file TEXT NOT NULL,
    kw TEXT NOT NULL,
    value TEXT NOT NULL,
    node_id TEXT,
    PRIMARY KEY (file, kw, value)
);

CREATE INDEX IF NOT EXISTS idx_arroyo_generator_roles_role ON arroyo_generator_roles (role);
CREATE INDEX IF NOT EXISTS idx_arroyo_keywords_kw ON arroyo_keywords (kw);

Queries

The role-filter query pattern: a module matches if the file has the requested role in arroyo_generator_roles (matched through the node_ancestors closure table for inheritance), or if the file has no role rows at all (applies to all roles), and the file does not have an exclusion for that role in arroyo_keywords (also checked through the closure table).

sql#+name: arroyo-unified-queries
-- Generator role queries
insertGeneratorRole:
INSERT OR IGNORE INTO arroyo_generator_roles (file, node_id, role) VALUES (?, ?, ?);

deleteGeneratorRolesByFile:
DELETE FROM arroyo_generator_roles WHERE file = ?;

selectGeneratorRoles:
SELECT DISTINCT role FROM arroyo_generator_roles ORDER BY role;

-- Arroyo keyword queries
insertArroyoKeyword:
INSERT OR IGNORE INTO arroyo_keywords (file, kw, value, node_id) VALUES (?, ?, ?, ?);

deleteArroyoKeywordsByFile:
DELETE FROM arroyo_keywords WHERE file = ?;

selectArroyoKeywords:
SELECT file, value FROM arroyo_keywords WHERE kw = ? ORDER BY file;

selectArroyoKeywordsWithNodeId:
SELECT file, value, node_id FROM arroyo_keywords WHERE kw = ? ORDER BY file;

-- NixOS module queries (from arroyo_keywords kw='ARROYO_NIXOS_MODULE')
selectNixosModules:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_NIXOS_MODULE'
ORDER BY ak.value;

selectNixosModulesByRole:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_NIXOS_MODULE'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

selectNixosModulesByFile:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_NIXOS_MODULE' AND ak.file = ?
ORDER BY ak.value;

selectNixosModulesMeta:
SELECT DISTINCT ak.value AS module_path, ak.file, f.title, ak.node_id AS heading_id
FROM arroyo_keywords ak
LEFT JOIN files f ON ak.file = f.file
WHERE ak.kw = 'ARROYO_NIXOS_MODULE'
ORDER BY ak.value;

selectNixosModulesMetaByRole:
SELECT DISTINCT ak.value AS module_path, ak.file, f.title, ak.node_id AS heading_id
FROM arroyo_keywords ak
LEFT JOIN files f ON ak.file = f.file
WHERE ak.kw = 'ARROYO_NIXOS_MODULE'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

-- Home Manager module queries (from arroyo_keywords kw='ARROYO_HOME_MODULE')
selectHomeManagerModules:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_HOME_MODULE'
ORDER BY ak.value;

selectHomeManagerModulesByRole:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_HOME_MODULE'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

selectHomeManagerModulesByFile:
SELECT DISTINCT ak.value AS module_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_HOME_MODULE' AND ak.file = ?
ORDER BY ak.value;

selectHomeManagerModulesMeta:
SELECT DISTINCT ak.value AS module_path, ak.file, f.title, ak.node_id AS heading_id
FROM arroyo_keywords ak
LEFT JOIN files f ON ak.file = f.file
WHERE ak.kw = 'ARROYO_HOME_MODULE'
ORDER BY ak.value;

selectHomeManagerModulesMetaByRole:
SELECT DISTINCT ak.value AS module_path, ak.file, f.title, ak.node_id AS heading_id
FROM arroyo_keywords ak
LEFT JOIN files f ON ak.file = f.file
WHERE ak.kw = 'ARROYO_HOME_MODULE'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

-- Emacs module queries (from arroyo_keywords kw='ARROYO_EMACS_MODULE')
selectEmacsModules:
SELECT DISTINCT ak.value AS module_file
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_EMACS_MODULE'
ORDER BY ak.value;

selectEmacsModulesMeta:
SELECT DISTINCT ak.file, ak.value AS module_file, f.title, ak.node_id AS heading_id
FROM arroyo_keywords ak
LEFT JOIN files f ON ak.file = f.file
WHERE ak.kw = 'ARROYO_EMACS_MODULE'
ORDER BY ak.file;

-- Emacs epkg queries (from arroyo_keywords kw='ARROYO_HOME_EPKGS')
selectEmacsEpkgs:
SELECT DISTINCT ak.value AS override_block
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_HOME_EPKGS'
ORDER BY ak.file;

-- System overlay queries (from arroyo_keywords kw='ARROYO_SYSTEM_OVERLAY')
selectSystemOverlays:
SELECT DISTINCT ak.value AS overlay_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_SYSTEM_OVERLAY'
ORDER BY ak.value;

selectSystemOverlaysByRole:
SELECT DISTINCT ak.value AS overlay_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_SYSTEM_OVERLAY'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

-- Input file queries (from arroyo_keywords kw='ARROYO_INPUT')
selectInputs:
SELECT DISTINCT ak.value AS file_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_INPUT'
ORDER BY ak.value;

selectInputsByRole:
SELECT DISTINCT ak.value AS file_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_INPUT'
  AND (EXISTS (SELECT 1 FROM arroyo_generator_roles agr
               WHERE agr.file = ak.file
                 AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                      OR agr.node_id IS NULL)
                 AND agr.role = ?)
       OR NOT EXISTS (SELECT 1 FROM arroyo_generator_roles agr
                      WHERE agr.file = ak.file
                        AND (agr.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                             OR agr.node_id IS NULL)))
  AND NOT EXISTS (SELECT 1 FROM arroyo_keywords ak2
                  WHERE ak2.file = ak.file
                    AND (ak2.node_id IN (SELECT ancestor_id FROM node_ancestors WHERE node_id = ak.node_id)
                         OR ak2.node_id IS NULL)
                    AND ak2.kw IN ('ARROYO_SYSTEM_EXCLUDE', 'ARROYO_EXCLUDE_ROLE')
                    AND ak2.value = ?)
ORDER BY ak.value;

-- Output file queries (from arroyo_keywords kw='ARROYO_OUTPUT')
selectOutputs:
SELECT DISTINCT ak.value AS file_path
FROM arroyo_keywords ak
WHERE ak.kw = 'ARROYO_OUTPUT'
ORDER BY ak.value;

-- Age recipient queries (from arroyo_keywords kw='ARROYO_AGE_RECIPIENT')
-- Host headings declare :ARROYO_AGE_RECIPIENT: + :ARROYO_SYSTEM_ROLE: in their
-- PROPERTIES drawer. We join arroyo_generator_roles on the same node_id to
-- recover the role, and nodes (core schema) to recover the heading title
-- (the host name slug source).
selectAgeRecipients:
SELECT DISTINCT ak.value AS recipient, ak.file AS file_, n.title, ak.node_id, agr.role
FROM arroyo_keywords ak
JOIN arroyo_generator_roles agr ON agr.node_id = ak.node_id AND agr.file = ak.file
LEFT JOIN nodes n ON n.id = ak.node_id
WHERE ak.kw = 'ARROYO_AGE_RECIPIENT'
ORDER BY agr.role, n.title;

selectAgeRecipientsByRole:
SELECT DISTINCT ak.value AS recipient, ak.file AS file_, n.title, ak.node_id, agr.role
FROM arroyo_keywords ak
JOIN arroyo_generator_roles agr ON agr.node_id = ak.node_id AND agr.file = ak.file
LEFT JOIN nodes n ON n.id = ak.node_id
WHERE ak.kw = 'ARROYO_AGE_RECIPIENT'
  AND agr.role = ?
ORDER BY n.title;

Repository Implementation

All module queries read from arroyo_keywords. The role-filter queries pass the role parameter twice — once for the inclusion check and once for the exclusion check. Insert/delete for modules is handled by the generic insertArroyoKeyword / deleteArroyoKeywordsByFile methods.

kotlin#+name: arroyo-repo-unified-impl
    override suspend fun getNixosModules(role: String?): List<String> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectNixosModulesByRole(role, role).executeAsList()
            } else {
                database.arroyoQueries.selectNixosModules().executeAsList()
            }
        }
    }

    override suspend fun getNixosModulesMeta(role: String?): List<ModuleMeta> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectNixosModulesMetaByRole(role, role) { module_path, file_, title, heading_id ->
                    ModuleMeta(modulePath = module_path, file = file_, title = title, headingId = heading_id)
                }.executeAsList()
            } else {
                database.arroyoQueries.selectNixosModulesMeta { module_path, file_, title, heading_id ->
                    ModuleMeta(modulePath = module_path, file = file_, title = title, headingId = heading_id)
                }.executeAsList()
            }
        }
    }

    override suspend fun getHomeManagerModules(role: String?): List<String> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectHomeManagerModulesByRole(role, role).executeAsList()
            } else {
                database.arroyoQueries.selectHomeManagerModules().executeAsList()
            }
        }
    }

    override suspend fun getHomeManagerModulesMeta(role: String?): List<ModuleMeta> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectHomeManagerModulesMetaByRole(role, role) { module_path, file_, title, heading_id ->
                    ModuleMeta(modulePath = module_path, file = file_, title = title, headingId = heading_id)
                }.executeAsList()
            } else {
                database.arroyoQueries.selectHomeManagerModulesMeta { module_path, file_, title, heading_id ->
                    ModuleMeta(modulePath = module_path, file = file_, title = title, headingId = heading_id)
                }.executeAsList()
            }
        }
    }

    override suspend fun getEmacsModules(): List<String> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectEmacsModules().executeAsList()
        }
    }

    override suspend fun getEmacsModulesMeta(): List<EmacsModuleMeta> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectEmacsModulesMeta().executeAsList().map { row ->
                EmacsModuleMeta(
                    file = row.file_,
                    moduleFile = row.module_file,
                    title = row.title,
                    headingId = row.heading_id
                )
            }
        }
    }

    override suspend fun getEmacsEpkgs(): List<String> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectEmacsEpkgs().executeAsList()
        }
    }

    override suspend fun getSystemOverlays(role: String?): List<String> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectSystemOverlaysByRole(role, role).executeAsList()
            } else {
                database.arroyoQueries.selectSystemOverlays().executeAsList()
            }
        }
    }

    override suspend fun getInputs(role: String?): List<String> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectInputsByRole(role, role).executeAsList()
            } else {
                database.arroyoQueries.selectInputs().executeAsList()
            }
        }
    }

    override suspend fun getOutputs(): List<String> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectOutputs().executeAsList()
        }
    }

    override suspend fun getAgeRecipients(role: String?): List<AgeRecipient> {
        return withContext(Dispatchers.IO) {
            if (role != null) {
                database.arroyoQueries.selectAgeRecipientsByRole(role) { recipient, file_, title, node_id, role_ ->
                    AgeRecipient(recipient = recipient, file = file_, title = title, nodeId = node_id, role = role_)
                }.executeAsList()
            } else {
                database.arroyoQueries.selectAgeRecipients { recipient, file_, title, node_id, role_ ->
                    AgeRecipient(recipient = recipient, file = file_, title = title, nodeId = node_id, role = role_)
                }.executeAsList()
            }
        }
    }

    override suspend fun getGeneratorRoles(): List<String> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectGeneratorRoles().executeAsList()
        }
    }

    override suspend fun insertGeneratorRole(file: String, nodeId: String?, role: String) {
        database.arroyoQueries.insertGeneratorRole(file, nodeId, role)
    }

    override suspend fun deleteGeneratorRolesByFile(file: String) {
        database.arroyoQueries.deleteGeneratorRolesByFile(file)
    }

    override suspend fun getArroyoKeywords(kw: String): List<Pair<String, String>> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectArroyoKeywords(kw).executeAsList().map { row ->
                row.file_ to row.value_
            }
        }
    }

    override suspend fun getArroyoKeywordsWithNodeId(kw: String): List<Triple<String, String?, String>> {
        return withContext(Dispatchers.IO) {
            database.arroyoQueries.selectArroyoKeywordsWithNodeId(kw).executeAsList().map { row ->
                Triple(row.file_, row.node_id, row.value_)
            }
        }
    }

    override suspend fun insertArroyoKeyword(file: String, kw: String, value: String, nodeId: String?) {
        database.arroyoQueries.insertArroyoKeyword(file, kw, value, nodeId)
    }

    override suspend fun deleteArroyoKeywordsByFile(file: String) {
        database.arroyoQueries.deleteArroyoKeywordsByFile(file)
    }

    override suspend fun getFileTitle(filePath: String): String? {
        return withContext(Dispatchers.IO) {
            database.arcologyDatabaseQueries.selectFileTitle(filePath).executeAsOneOrNull()?.title
        }
    }

    override suspend fun deleteAllByFile(file: String) {
        deleteGeneratorRolesByFile(file)
        deleteArroyoKeywordsByFile(file)
        deleteNodeAncestorsByFile(file)
    }

    override suspend fun insertNodeAncestor(nodeId: String, ancestorId: String) {
        database.arcologyDatabaseQueries.insertNodeAncestor(nodeId, ancestorId)
    }

    override suspend fun deleteNodeAncestorsByFile(file: String) {
        database.arcologyDatabaseQueries.deleteNodeAncestorsByFile(file)
    }

    override suspend fun getFlakeInputs(orgDir: String): List<FlakeInput> {
        return withContext(Dispatchers.IO) {
            val files = database.arcologyDatabaseQueries.selectAllFiles().executeAsList()
            val subdirs = files.map { it.file_.substringBefore("/") }.distinct().sorted()
            subdirs.map { name ->
                FlakeInput(name = name, url = "path:$orgDir/$name")
            }
        }
    }

    override suspend fun getNixosRoleModules(role: String, orgDir: String): List<FlakeModuleRef> {
        return withContext(Dispatchers.IO) {
            val meta = getNixosModulesMeta(role)
            meta.map { m ->
                FlakeModuleRef(modulePath = m.modulePath)
            }
        }
    }

    override suspend fun getHomeRoleModules(role: String, orgDir: String): List<FlakeModuleRef> {
        return withContext(Dispatchers.IO) {
            val meta = getHomeManagerModulesMeta(role)
            meta.map { m ->
                FlakeModuleRef(modulePath = m.modulePath)
            }
        }
    }

Arroyo Indexer Plugin

The plugin is a single-phase process: collect all ARROYO_* keywords from the file (properties, preamble, sections), then dispatch each keyword. ARROYO_SYSTEM_ROLE / ARROYO_ROLE go into arroyo_generator_roles; everything else goes into the unified arroyo_keywords table. Role inclusion/exclusion is computed at query time — no junction tables, no pre-computed role membership.

Design

Implements the IndexerPlugin interface (defined in roam/models.org). Holds its own ArroyoRepository reference — no shared state with other plugins.

kotlin#+name: arroyo-plugin-header
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.ParseResult
import computer.whatthefuck.arcology.domain.FileProperty
import xyz.lepisma.orgmode.OrgSection
import xyz.lepisma.orgmode.OrgChunk

/**
 * Indexer plugin that extracts ARROYO_* keywords from parsed org files
 * and populates the denormalized Arroyo tables.
 *
 * Uses a two-phase approach: first collect all roles and excluded roles
 * from the file, then apply them to each module destination. This matches
 * the old Python semantics where a module's role membership is determined
 * by the file-level ARROYO_SYSTEM_ROLE / ARROYO_SYSTEM_EXCLUDE keywords,
 * not by per-module annotations.
 */
class ArroyoIndexerPlugin(
    private val repository: ArroyoRepository
) : IndexerPlugin {

    private data class ArroyoKeywordTriple(
        val nodeId: String?,
        val key: String,
        val value: String
    )

Keyword Extraction

Two extraction strategies: extractPreambleArroyoKeywords uses a regex to scan =#+ARROYO_= lines from raw content before the first heading, and extractArroyoKeywordsFromSection recursively walks the parsed section tree looking for OrgKeywordLine chunks with =ARROYO_= prefixes. Note: =#+ARROYO_ keyword lines are always file-scoped (nodeId null) regardless of where they appear — heading-scoping only happens via =:ARROYO_ in heading :PROPERTIES:= drawers.

kotlin#+name: arroyo-plugin-extraction
    private fun extractArroyoKeywordsFromSection(section: OrgSection, filePath: String): List<ArroyoKeywordTriple> {
        val results = mutableListOf<ArroyoKeywordTriple>()

        section.body.forEach { chunk ->
            if (chunk is OrgChunk.OrgKeywordLine && chunk.keyword.startsWith("ARROYO_")) {
                val value = chunk.value.trim()
                if (value.isNotEmpty()) {
                    // #+ARROYO_*: keyword lines are always file-scoped (null nodeId)
                    // Heading-scoping only happens via :ARROYO_*: in heading :PROPERTIES: drawers
                    results.add(ArroyoKeywordTriple(null, chunk.keyword, value))
                }
            }
            if (chunk is OrgSection) {
                results.addAll(extractArroyoKeywordsFromSection(chunk, filePath))
            }
        }

        return results
    }

    private fun extractPreambleArroyoKeywords(content: String): List<Pair<String, String>> {
        val results = mutableListOf<Pair<String, String>>()
        val regex = """^#\+ARROYO_(\w+):\s*(.+)$""".toRegex(RegexOption.MULTILINE)
        regex.findAll(content).forEach { match ->
            val key = "ARROYO_${match.groupValues[1]}"
            val value = match.groupValues[2].trim()
            if (value.isNotEmpty()) {
                results.add(key to value)
            }
        }
        return results
    }

Core Logic

The main onFileIndexed method clears all Arroyo data for the file, collects every keyword into a flat list, then dispatches each keyword. ARROYO_SYSTEM_ROLE / ARROYO_ROLE go into arroyo_generator_roles; everything else goes into the unified arroyo_keywords table. Role inclusion/exclusion is computed at query time by joining against arroyo_generator_roles and self-joining arroyo_keywords for exclusion entries. Multi-valued keywords (roles, modules, excludes, overlays, wants) are split on whitespace so that a single :PROPERTIES: drawer entry like :ARROYO_SYSTEM_ROLE: edge server or :ARROYO_MODULE_WANTS: cce/diminish cce/configure_packaging expands into separate rows — this matches the semantics of multiple #+ARROYO_*: keyword lines, which org-mode property drawers would otherwise collapse to a single value.

kotlin#+name: arroyo-plugin-core
    override suspend fun onFileIndexed(result: ParseResult.Success) {
        val filePath = result.file.path
        repository.deleteAllByFile(filePath)

        // Collect all ARROYO_* keywords from preamble keyword lines, section keyword lines,
        // and :PROPERTIES: drawers (both file-level and heading-level).
        // fileProperties is NOT used — nodeProperties already captures file-level
        // :PROPERTIES: drawer entries with the file's :ID: as nodeId.
        val allKeywords = mutableListOf<ArroyoKeywordTriple>()

        // Preamble #+ARROYO_*: keyword lines (always file-scoped, null nodeId)
        extractPreambleArroyoKeywords(result.content).forEach { (key, value) ->
            allKeywords.add(ArroyoKeywordTriple(null, key, value))
        }

        // Section body #+ARROYO_*: keyword lines (always file-scoped, null nodeId)
        result.document?.content?.forEach { section ->
            allKeywords.addAll(extractArroyoKeywordsFromSection(section, filePath))
        }

        // ARROYO_* properties from :PROPERTIES: drawers (both file-level and heading-level)
        // File-level properties get the file's :ID: as nodeId; heading-level get the heading's :ID:
        result.nodeProperties.forEach { prop ->
            if (prop.key.startsWith("ARROYO_")) {
                allKeywords.add(ArroyoKeywordTriple(prop.nodeId, prop.key, prop.value ?: ""))
            }
        }

        // Dispatch: roles go to arroyo_generator_roles, everything else to arroyo_keywords.
        // Multi-valued keywords (roles, modules, excludes, wants) are split on whitespace
        // so that :PROPERTIES: drawer entries like :ARROYO_SYSTEM_ROLE: edge server
        // or :ARROYO_MODULE_WANTS: cce/diminish cce/configure_packaging
        // are expanded into separate rows, matching the semantics of multiple
        // #+ARROYO_*: keyword lines.
        val multiValuedKeys = setOf(
            "ARROYO_SYSTEM_ROLE", "ARROYO_ROLE",
            "ARROYO_SYSTEM_EXCLUDE", "ARROYO_EXCLUDE_ROLE",
            "ARROYO_NIXOS_MODULE", "ARROYO_HOME_MODULE", "ARROYO_EMACS_MODULE",
            "ARROYO_HOME_EPKGS", "ARROYO_INPUT", "ARROYO_SYSTEM_OVERLAY",
            "ARROYO_MODULE_WANTS", "ARROYO_MODULE_WANTED"
        )
        allKeywords.forEach { (nodeId, key, value) ->
            val values = if (key in multiValuedKeys) {
                value.split(Regex("\\s+")).filter { it.isNotEmpty() }
            } else {
                listOf(value)
            }
            values.forEach { v ->
                when (key) {
                    "ARROYO_SYSTEM_ROLE", "ARROYO_ROLE" ->
                        repository.insertGeneratorRole(filePath, nodeId, v)
                    else ->
                        repository.insertArroyoKeyword(filePath, key, v, nodeId)
                }
            }
        }

        // Populate node ancestor closure table for property inheritance
        val nodeMap = result.nodes.associateBy { it.id }
        result.nodes.forEach { node ->
            repository.insertNodeAncestor(node.id, node.id)
            var current = node.parentNodeId
            while (current != null) {
                repository.insertNodeAncestor(node.id, current)
                current = nodeMap[current]?.parentNodeId
            }
        }
    }

    override suspend fun onFileRemoved(filePath: String) {
        repository.deleteAllByFile(filePath)
    }

ArroyoScriptHost — LuaJ eval for generator blocks

Runtime evaluation of :eval arroyo blocks using the Lua scripting language via LuaJ (http://luaj.org/luaj.html). The implementation is ~jvmMain~-only; commonMain sees only the ArroyoTangleContext interface.

Each tangle() call creates a single Globals instance. Before each block, the relevant arroyo.* library functions and table data bindings are set as Lua globals. The script is loaded and called; its return value is captured as the output string. Errors are thrown to the caller, which wraps them in EvalError warnings.

The arroyo table exposes:

  • arroyo.nixos_modules(role) — wraps ArroyoRepository.getNixosModules(role)

  • arroyo.home_modules(role) — wraps ArroyoRepository.getHomeManagerModules(role)

  • arroyo.emacs_snippets() — wraps ArroyoRepository.getEmacsModules()

  • arroyo.epkg_overrides() — returns the file contents of each epkg override file (paths from ArroyoRepository.getEmacsEpkgs() resolved relative to /nix/)

  • arroyo.system_overlays(role) — returns the file contents of each overlay file (paths from ArroyoRepository.getSystemOverlays(role) resolved relative to /nix/)

  • arroyo.inputs(role) — returns the file contents of each input file (paths from ArroyoRepository.getInputs(role) resolved relative to /nix/)

  • arroyo.outputs() — returns the file contents of each output file (paths from ArroyoRepository.getOutputs() resolved relative to /nix/)

Table data bindings are converted from List<List<String>> to Lua 1-indexed nested tables. String bindings (from quoted :var values like :var role="endpoint") are set as plain Lua strings.

kotlin#+name: arroyo-script-host:tangle ../src/jvmMain/kotlin/computer/whatthefuck/arroyo/ArroyoScriptHost.kt
package computer.whatthefuck.arroyo

import kotlinx.coroutines.runBlocking
import org.luaj.vm2.Globals
import org.luaj.vm2.LuaTable
import org.luaj.vm2.LuaValue
import org.luaj.vm2.lib.OneArgFunction
import org.luaj.vm2.lib.TwoArgFunction
import org.luaj.vm2.lib.ZeroArgFunction
import org.luaj.vm2.lib.jse.JsePlatform

class ArroyoScriptHost(
    private val repository: ArroyoRepository,
    private val orgDir: String
) : ArroyoTangleContext {

    private val globals: Globals = JsePlatform.standardGlobals()

    init {
        val arroyoTable = LuaTable()
        arroyoTable["nixos_modules"] = NixosModulesFunction()
        arroyoTable["nixos_modules_meta"] = NixosModulesMetaFunction()
        arroyoTable["home_modules"] = HomeModulesFunction()
        arroyoTable["home_modules_meta"] = HomeModulesMetaFunction()
        arroyoTable["emacs_snippets"] = EmacsSnippetsFunction()
        arroyoTable["emacs_init"] = EmacsInitFunction()
        arroyoTable["emacs_modules_meta"] = EmacsModulesMetaFunction()
        arroyoTable["epkg_overrides"] = EpkgOverridesFunction()
        arroyoTable["file_title_for"] = FileTitleForFunction()
        arroyoTable["org_dir"] = OrgDirFunction()
        arroyoTable["flake_inputs"] = FlakeInputsFunction()
        arroyoTable["nixos_role_modules"] = NixosRoleModulesFunction()
        arroyoTable["home_role_modules"] = HomeRoleModulesFunction()
        arroyoTable["system_overlays"] = SystemOverlaysFunction()
        arroyoTable["inputs"] = InputsFunction()
        arroyoTable["outputs"] = OutputsFunction()
        arroyoTable["age_recipients"] = AgeRecipientsFunction()
        arroyoTable["age_recipients_all"] = AgeRecipientsAllFunction()
        globals.set("arroyo", arroyoTable)
    }

    override fun nixosModules(role: String?): List<String> = runBlocking {
        repository.getNixosModules(role)
    }

    override fun nixosModulesMeta(role: String?): List<ModuleMeta> = runBlocking {
        repository.getNixosModulesMeta(role)
    }

    override fun homeModules(role: String?): List<String> = runBlocking {
        repository.getHomeManagerModules(role)
    }

    override fun homeModulesMeta(role: String?): List<ModuleMeta> = runBlocking {
        repository.getHomeManagerModulesMeta(role)
    }

    override fun emacsSnippets(): List<String> = runBlocking {
        val modules = repository.getEmacsModules()
        if (modules.isEmpty()) return@runBlocking emptyList()

        val allWants = repository.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTS")
        val allWanted = repository.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTED")

        // Group wants/wanted by node_id (null = file-level, applies to all headings in file)
        val wantsByNodeId = allWants.groupBy({ it.second ?: it.first }, { it.third })
        val wantedByNodeId = allWanted.groupBy({ it.second ?: it.first }, { it.third })

        // Build node_id → module_name map from EMACS_MODULE entries
        val nodeIdToModule = mutableMapOf<String, String>()
        val moduleToNodeId = mutableMapOf<String, String>()
        val emacsKeywords = repository.getArroyoKeywordsWithNodeId("ARROYO_EMACS_MODULE")
        emacsKeywords.forEach { (file, nodeId, moduleFile) ->
            val effectiveId = nodeId ?: file
            if (effectiveId !in nodeIdToModule) nodeIdToModule[effectiveId] = moduleFile
            moduleToNodeId[moduleFile] = effectiveId
        }

        // Wants values are now heading IDs — resolve directly
        fun resolveModuleDep(dep: String): String? {
            return nodeIdToModule[dep]
        }

        val wantsMap = mutableMapOf<String, MutableList<String>>()
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            val deps = wantsByNodeId[nodeId]?.mapNotNull { resolveModuleDep(it) }?.filter { it.isNotEmpty() } ?: emptyList()
            wantsMap[moduleFile] = deps.toMutableList()
        }

        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            wantedByNodeId[nodeId]?.mapNotNull { resolveModuleDep(it) }?.filter { it.isNotEmpty() }?.forEach { target ->
                if (target in wantsMap) {
                    wantsMap.getOrPut(target) { mutableListOf() }.add(moduleFile)
                }
            }
        }

        try {
            topologicalSort(wantsMap)
        } catch (e: IllegalStateException) {
            wantsMap.keys.toList()
        }
    }

    override fun emacsInit(): String = runBlocking {
        val sorted = emacsSnippets()
        val homeDir = System.getProperty("user.home")
        val sb = StringBuilder()
        sorted.forEach { moduleFile ->
            val fullPath = java.io.File(homeDir, "nix/lisp/$moduleFile.el")
            if (fullPath.exists()) {
                sb.append(fullPath.readText())
                sb.append("\n")
            }
        }
        sb.toString()
    }

    override fun emacsModulesMeta(): List<EmacsModuleMeta> = runBlocking {
        repository.getEmacsModulesMeta()
    }

    override fun fileTitleFor(filePath: String): String? = runBlocking {
        repository.getFileTitle(filePath)
    }

    override fun epkgOverrides(): List<String> = runBlocking {
        val paths = repository.getEmacsEpkgs()
        paths.mapNotNull { path ->
            val fullPath = java.io.File(System.getProperty("user.home"), "nix/$path")
            if (fullPath.exists()) fullPath.readText() else null
        }
    }

    override fun flakeInputs(orgDir: String): List<FlakeInput> = runBlocking {
        repository.getFlakeInputs(orgDir)
    }

    override fun nixosRoleModules(role: String, orgDir: String): List<FlakeModuleRef> = runBlocking {
        repository.getNixosRoleModules(role, orgDir)
    }

    override fun homeRoleModules(role: String, orgDir: String): List<FlakeModuleRef> = runBlocking {
        repository.getHomeRoleModules(role, orgDir)
    }

    override fun systemOverlays(role: String?): List<String> = runBlocking {
        val paths = repository.getSystemOverlays(role)
        paths.mapNotNull { path ->
            val fullPath = java.io.File(System.getProperty("user.home"), "nix/$path")
            if (fullPath.exists()) fullPath.readText() else null
        }
    }

    override fun inputs(role: String?): List<String> = runBlocking {
        val paths = repository.getInputs(role)
        paths.mapNotNull { path ->
            val fullPath = java.io.File(System.getProperty("user.home"), "nix/$path")
            if (fullPath.exists()) fullPath.readText() else null
        }
    }

    override fun outputs(): List<String> = runBlocking {
        val paths = repository.getOutputs()
        paths.mapNotNull { path ->
            val fullPath = java.io.File(System.getProperty("user.home"), "nix/$path")
            if (fullPath.exists()) fullPath.readText() else null
        }
    }

    override fun ageRecipients(role: String?): List<AgeRecipient> = runBlocking {
        repository.getAgeRecipients(role)
    }

    override fun tableFor(name: String): List<List<String>> = emptyList()

    override fun evalBlock(script: String, varBindings: Map<String, List<List<String>>>, stringBindings: Map<String, String>): String {
        for ((name, data) in varBindings) {
            globals.set(name, toLuaTable(data))
        }
        for ((name, value) in stringBindings) {
            globals.set(name, LuaValue.valueOf(value))
        }
        val chunk = globals.load(script)
        val result = chunk.call()
        for ((name, _) in varBindings) {
            globals.set(name, LuaValue.NIL)
        }
        for ((name, _) in stringBindings) {
            globals.set(name, LuaValue.NIL)
        }
        return if (result.isnil()) "" else result.tojstring()
    }

    private fun toLuaTable(rows: List<List<String>>): LuaTable {
        val table = LuaTable()
        for ((i, row) in rows.withIndex()) {
            val rowTable = LuaTable()
            for ((j, cell) in row.withIndex()) {
                rowTable.set(j + 1, LuaValue.valueOf(cell))
            }
            table.set(i + 1, rowTable)
        }
        return table
    }

    private inner class NixosModulesFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val modules = nixosModules(role)
            val t = LuaTable()
            modules.forEachIndexed { i, m -> t.set(i + 1, LuaValue.valueOf(m)) }
            return t
        }
    }

    private inner class HomeModulesFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val modules = homeModules(role)
            val t = LuaTable()
            modules.forEachIndexed { i, m -> t.set(i + 1, LuaValue.valueOf(m)) }
            return t
        }
    }

    private inner class EmacsSnippetsFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val snippets = emacsSnippets()
            val t = LuaTable()
            snippets.forEachIndexed { i, s -> t.set(i + 1, LuaValue.valueOf(s)) }
            return t
        }
    }

    private inner class EmacsInitFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            return LuaValue.valueOf(emacsInit())
        }
    }

    private inner class EpkgOverridesFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val overrides = epkgOverrides()
            val t = LuaTable()
            overrides.forEachIndexed { i, o -> t.set(i + 1, LuaValue.valueOf(o)) }
            return t
        }
    }

    private inner class NixosModulesMetaFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val meta = nixosModulesMeta(role)
            val t = LuaTable()
            meta.forEachIndexed { i, m ->
                val row = LuaTable()
                row.set("module_path", m.modulePath)
                row.set("file", m.file)
                row.set("title", if (m.title != null) LuaValue.valueOf(m.title) else LuaValue.NIL)
                row.set("heading_id", if (m.headingId != null) LuaValue.valueOf(m.headingId) else LuaValue.NIL)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class HomeModulesMetaFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val meta = homeModulesMeta(role)
            val t = LuaTable()
            meta.forEachIndexed { i, m ->
                val row = LuaTable()
                row.set("module_path", m.modulePath)
                row.set("file", m.file)
                row.set("title", if (m.title != null) LuaValue.valueOf(m.title) else LuaValue.NIL)
                row.set("heading_id", if (m.headingId != null) LuaValue.valueOf(m.headingId) else LuaValue.NIL)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class EmacsModulesMetaFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val meta = emacsModulesMeta()
            val t = LuaTable()
            meta.forEachIndexed { i, m ->
                val row = LuaTable()
                row.set("file", m.file)
                row.set("module_file", m.moduleFile)
                row.set("title", if (m.title != null) LuaValue.valueOf(m.title) else LuaValue.NIL)
                row.set("heading_id", if (m.headingId != null) LuaValue.valueOf(m.headingId) else LuaValue.NIL)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class FileTitleForFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val path = arg.tojstring()
            val title = fileTitleFor(path)
            return if (title != null) LuaValue.valueOf(title) else LuaValue.NIL
        }
    }

    private inner class OrgDirFunction : ZeroArgFunction() {
        override fun call(): LuaValue = LuaValue.valueOf(orgDir)
    }

    private inner class FlakeInputsFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val inputs = flakeInputs(orgDir)
            val t = LuaTable()
            inputs.forEachIndexed { i, input ->
                val row = LuaTable()
                row.set("name", input.name)
                row.set("url", input.url)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class NixosRoleModulesFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = arg.tojstring()
            val modules = nixosRoleModules(role, orgDir)
            val t = LuaTable()
            modules.forEachIndexed { i, m ->
                val row = LuaTable()
                row.set("module_path", m.modulePath)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class HomeRoleModulesFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = arg.tojstring()
            val modules = homeRoleModules(role, orgDir)
            val t = LuaTable()
            modules.forEachIndexed { i, m ->
                val row = LuaTable()
                row.set("module_path", m.modulePath)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class SystemOverlaysFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val overlays = systemOverlays(role)
            val t = LuaTable()
            overlays.forEachIndexed { i, o -> t.set(i + 1, LuaValue.valueOf(o)) }
            return t
        }
    }

    private inner class InputsFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val contents = inputs(role)
            val t = LuaTable()
            contents.forEachIndexed { i, c -> t.set(i + 1, LuaValue.valueOf(c)) }
            return t
        }
    }

    private inner class OutputsFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val contents = outputs()
            val t = LuaTable()
            contents.forEachIndexed { i, c -> t.set(i + 1, LuaValue.valueOf(c)) }
            return t
        }
    }

    private fun slugifyTitle(title: String?): String {
        if (title.isNullOrBlank()) return "unknown-host"
        return title.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
    }

    private inner class AgeRecipientsFunction : OneArgFunction() {
        override fun call(arg: LuaValue): LuaValue {
            val role = if (arg.isnil()) null else arg.tojstring()
            val recipients = ageRecipients(role)
            val t = LuaTable()
            recipients.forEachIndexed { i, r ->
                val row = LuaTable()
                row.set("host", slugifyTitle(r.title))
                row.set("recipient", r.recipient)
                row.set("role", r.role)
                row.set("title", if (r.title != null) LuaValue.valueOf(r.title) else LuaValue.NIL)
                row.set("node_id", if (r.nodeId != null) LuaValue.valueOf(r.nodeId) else LuaValue.NIL)
                t.set(i + 1, row)
            }
            return t
        }
    }

    private inner class AgeRecipientsAllFunction : ZeroArgFunction() {
        override fun call(): LuaValue {
            val recipients = ageRecipients(null)
            val t = LuaTable()
            recipients.forEachIndexed { i, r ->
                val row = LuaTable()
                row.set("host", slugifyTitle(r.title))
                row.set("recipient", r.recipient)
                row.set("role", r.role)
                row.set("title", if (r.title != null) LuaValue.valueOf(r.title) else LuaValue.NIL)
                row.set("node_id", if (r.nodeId != null) LuaValue.valueOf(r.nodeId) else LuaValue.NIL)
                t.set(i + 1, row)
            }
            return t
        }
    }
}

Helpers

Topological Sort

Kahn's algorithm for sorting Emacs snippets by their ARROYO_MODULE_WANTS dependency chain. Input is a map of node → list of dependencies. Output is a topologically ordered list. Cycles are detected and reported.

kotlin#+name: topological-sort:tangle ../src/commonMain/kotlin/computer/whatthefuck/arroyo/TopologicalSort.kt
package computer.whatthefuck.arroyo

/**
 * Topological sort using Kahn's algorithm.
 * Input: map of node -> list of dependencies (wants).
 * Output: nodes in dependency order (dependencies first).
 * Throws on cycle detection.
 */
fun topologicalSort(nodes: Map<String, List<String>>): List<String> {
    val inDegree = mutableMapOf<String, Int>()
    val adjacency = mutableMapOf<String, MutableList<String>>()

    // Initialize all nodes
    nodes.keys.forEach { node ->
        inDegree.putIfAbsent(node, 0)
        adjacency.putIfAbsent(node, mutableListOf())
    }

    // Build graph: edge from dep -> node means "dep must come before node"
    nodes.forEach { (node, deps) ->
        deps.forEach { dep ->
            // dep might not be in our set; that's fine, it's an external dependency
            if (dep in nodes) {
                adjacency.getOrPut(dep) { mutableListOf() }.add(node)
                inDegree[node] = (inDegree[node] ?: 0) + 1
            }
        }
    }

    val queue = ArrayDeque<String>()
    inDegree.filter { it.value == 0 }.keys.forEach { queue.add(it) }

    val result = mutableListOf<String>()
    while (queue.isNotEmpty()) {
        val node = queue.removeFirst()
        result.add(node)
        adjacency[node]?.forEach { neighbor ->
            inDegree[neighbor] = (inDegree[neighbor] ?: 1) - 1
            if (inDegree[neighbor] == 0) {
                queue.add(neighbor)
            }
        }
    }

    if (result.size != nodes.size) {
        val cycle = nodes.keys - result.toSet()
        throw IllegalStateException("Cycle detected involving: $cycle")
    }

    return result
}

CLI Generator Commands

Five CliktCommand subclasses that query the Arroyo database and print results to stdout. Each accepts --db for the database path. EmacsSnippetsCommand also topologically sorts by ARROYO_MODULE_WANTS dependencies.

FloodCommand treats a :eval arroyo block whose Lua script throws as a hard error, not a recoverable warning. When tangle() raises TangleEvalException, the command prints the error and — unless --ignore-errors is set — exits non-zero immediately without tangling further files. With --ignore-errors the failed file is skipped and counted toward the failure total. Embedding broken Lua source (or nothing) in the tangled output is worse than failing loudly.

FloodCommand also supports a --rebuild ACTION --on HOSTNAME pair. --on may be repeated to deploy to multiple hosts. When both are supplied the flood runs as usual, then two extra steps execute: nix flake update is run in ~~/nix~ to refresh the flake inputs, and ~nixos-rebuild ACTION --flake ~/nix#HOSTNAME --target-host=HOSTNAME --ask-sudo-password --sudo~ is invoked for each host in order. The rebuild steps are skipped in --dry-run mode.

The --no-update flag skips the nix flake update step entirely, leaving the flake inputs pinned at their current revisions. When --rebuild build is the action, the per-host command simplifies to ~nixos-rebuild build --flake ~/nix#HOSTNAME~ — no --target-host, --ask-sudo-password, or --sudo, since build only evaluates locally and never touches a remote host or invokes sudo. Any other action (e.g. switch, test, boot) uses the full remote-deploy form.

kotlin#+name: generators-commands:tangle ../src/jvmMain/kotlin/computer/whatthefuck/arroyo/GeneratorCommands.kt
package computer.whatthefuck.arroyo

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.multiple
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.path
import computer.whatthefuck.arcology.database.DatabaseFactory
import kotlinx.coroutines.runBlocking
import java.io.File
import kotlin.io.path.pathString

class NixosModulesCommand : CliktCommand(name = "nixos-modules", help = "List NixOS modules from ARROYO_NIXOS_MODULE") {
    private val role by option("--role", "-r", help = "Filter by system role")
    private val dbPath by option("--db", help = "Database path").default("arcology.db")

    override fun run() = runBlocking {
        if (!File(dbPath).exists()) {
            echo("Database not found: $dbPath", err = true)
            echo("Run 'arcology index <directory>' first.", err = true)
            return@runBlocking
        }
        val database = DatabaseFactory.createDatabase(dbPath)
        val repo = ArroyoRepositoryImpl(database)
        val modules = repo.getNixosModules(role)
        if (modules.isEmpty()) {
            echo("No NixOS modules found${if (role != null) " for role '$role'" else ""}.")
        } else {
            modules.forEach { echo(it) }
        }
    }
}

class HomeModulesCommand : CliktCommand(name = "home-modules", help = "List Home Manager modules from ARROYO_HOME_MODULE") {
    private val role by option("--role", "-r", help = "Filter by system role")
    private val dbPath by option("--db", help = "Database path").default("arcology.db")

    override fun run() = runBlocking {
        if (!File(dbPath).exists()) {
            echo("Database not found: $dbPath", err = true)
            echo("Run 'arcology index <directory>' first.", err = true)
            return@runBlocking
        }
        val database = DatabaseFactory.createDatabase(dbPath)
        val repo = ArroyoRepositoryImpl(database)
        val modules = repo.getHomeManagerModules(role)
        if (modules.isEmpty()) {
            echo("No Home Manager modules found${if (role != null) " for role '$role'" else ""}.")
        } else {
            modules.forEach { echo(it) }
        }
    }
}

class EmacsSnippetsCommand : CliktCommand(name = "emacs-snippets", help = "List Emacs snippets from ARROYO_EMACS_MODULE, topologically sorted") {
    private val dbPath by option("--db", help = "Database path").default("arcology.db")

    override fun run() = runBlocking {
        if (!File(dbPath).exists()) {
            echo("Database not found: $dbPath", err = true)
            echo("Run 'arcology index <directory>' first.", err = true)
            return@runBlocking
        }
        val database = DatabaseFactory.createDatabase(dbPath)
        val repo = ArroyoRepositoryImpl(database)
        val modules = repo.getEmacsModules()

        if (modules.isEmpty()) {
            echo("No Emacs snippets found.")
            return@runBlocking
        }

        // Query ARROYO_MODULE_WANTS and ARROYO_MODULE_WANTED from arroyo_keywords
        val allWants = repo.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTS")
        val allWanted = repo.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTED")

        // Group wants/wanted by node_id (null = file-level, applies to all headings in file)
        val wantsByNodeId = allWants.groupBy({ it.second ?: it.first }, { it.third })
        val wantedByNodeId = allWanted.groupBy({ it.second ?: it.first }, { it.third })

        // Build node_id → module_name map from EMACS_MODULE entries
        val nodeIdToModule = mutableMapOf<String, String>()
        val moduleToNodeId = mutableMapOf<String, String>()
        val emacsKeywords = repo.getArroyoKeywordsWithNodeId("ARROYO_EMACS_MODULE")
        emacsKeywords.forEach { (file, nodeId, moduleFile) ->
            val effectiveId = nodeId ?: file
            if (effectiveId !in nodeIdToModule) nodeIdToModule[effectiveId] = moduleFile
            moduleToNodeId[moduleFile] = effectiveId
        }

        // Wants values are now heading IDs — resolve directly
        fun resolveModuleDep(dep: String): String? {
            return nodeIdToModule[dep]
        }

        // Build dependency map for topological sort
        val wantsMap = mutableMapOf<String, MutableList<String>>()
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            val deps = wantsByNodeId[nodeId]?.mapNotNull { resolveModuleDep(it) }?.filter { it.isNotEmpty() } ?: emptyList()
            wantsMap[moduleFile] = deps.toMutableList()
        }

        // Add reverse dependency edges from wanted
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            wantedByNodeId[nodeId]?.mapNotNull { resolveModuleDep(it) }?.filter { it.isNotEmpty() }?.forEach { target ->
                if (target in wantsMap) {
                    wantsMap.getOrPut(target) { mutableListOf() }.add(moduleFile)
                }
            }
        }

        val sorted = try {
            topologicalSort(wantsMap)
        } catch (e: IllegalStateException) {
            echo("Warning: ${e.message}", err = true)
            wantsMap.keys.toList()
        }

        sorted.forEach { echo(it) }
    }
}

class EmacsEpkgsCommand : CliktCommand(name = "emacs-epkgs", help = "List Emacs epkg overrides from ARROYO_HOME_EPKGS") {
    private val dbPath by option("--db", help = "Database path").default("arcology.db")

    override fun run() = runBlocking {
        if (!File(dbPath).exists()) {
            echo("Database not found: $dbPath", err = true)
            echo("Run 'arcology index <directory>' first.", err = true)
            return@runBlocking
        }
        val database = DatabaseFactory.createDatabase(dbPath)
        val repo = ArroyoRepositoryImpl(database)
        val epkgs = repo.getEmacsEpkgs()
        if (epkgs.isEmpty()) {
            echo("No Emacs epkg overrides found.")
        } else {
            epkgs.forEach { echo(it) }
        }
    }
}

class KeywordsCommand : CliktCommand(name = "keywords", help = "Look up Arroyo keywords (ARROYO_TANGLE_THIS, etc.)") {
    private val keyword by argument(help = "Keyword to look up (e.g. ARROYO_TANGLE_THIS)")
    private val dbPath by option("--db", help = "Database path").default("arcology.db")

    override fun run() = runBlocking {
        if (!File(dbPath).exists()) {
            echo("Database not found: $dbPath", err = true)
            echo("Run 'arcology index <directory>' first.", err = true)
            return@runBlocking
        }
        val database = DatabaseFactory.createDatabase(dbPath)
        val repo = ArroyoRepositoryImpl(database)
        val results = repo.getArroyoKeywords(keyword)
        if (results.isEmpty()) {
            echo("No results for keyword '$keyword'.")
        } else {
            results.forEach { (file, value) ->
                echo("$file: $value")
            }
        }
    }
}

class FloodCommand : CliktCommand(name = "flood", help = "Full rebuild: index org dir, tangle files with eval") {
    private val orgDir by option("--org-dir", "-d", help = "Org directory to index and tangle")
        .path(canBeDir = true, canBeFile = false)
        .default(java.nio.file.Paths.get(System.getProperty("user.home"), "org"))
    private val dbPath by option("--db", help = "Database path")
        .default("arcology.db")
    private val role by option("--role", "-r", help = "Filter by system role (space-delimited match)")
    private val dryRun by option("--dry-run", "-n", help = "Show what would be done without writing").flag(default = false)
    private val ignoreErrors by option("--ignore-errors", help = "Continue on file failure").flag(default = false)
    private val allModules by option("--all-modules", "-a", help = "Tangle all files with ARROYO_NIXOS_MODULE, ARROYO_HOME_MODULE, ARROYO_EMACS_MODULE, or ARROYO_TANGLE_THIS").flag(default = false)
    private val rebuildAction by option("--rebuild", help = "After tangling, run nix flake update and nixos-rebuild ACTION. Requires --on")
    private val rebuildHosts by option("--on", help = "Hostname to deploy with nixos-rebuild. May be repeated. Requires --rebuild").multiple()
    private val noUpdate by option("--no-update", help = "Skip 'nix flake update' before nixos-rebuild").flag(default = false)
    private val verbose by option("--verbose", "-v", help = "List every matched file in dry-run, and every tangle output per file").flag(default = false)

    override fun run() {
        val expandedOrgDir = expandTilde(orgDir.pathString)
        val databasePath = if (dbPath == "arcology.db") {
            java.io.File(expandedOrgDir, "arcology.db").absolutePath
        } else {
            dbPath
        }
        val effectiveRole = role

        echo("Arroyo Flood")
        echo("  Org directory: $expandedOrgDir")
        echo("  Database: $databasePath")
        if (effectiveRole != null) echo("  Role filter: $effectiveRole")
        if (allModules) echo("  Mode: all modules (ARROYO_NIXOS_MODULE, ARROYO_HOME_MODULE, ARROYO_EMACS_MODULE, ARROYO_TANGLE_THIS)")
        if (dryRun) echo("  Dry run: yes")
        val rebuildError = validateRebuildOptions(rebuildAction, rebuildHosts)
        if (rebuildError != null) {
            echo("  ERROR: $rebuildError", err = true)
            kotlin.system.exitProcess(1)
        }
        if (rebuildAction != null && rebuildHosts.isNotEmpty()) {
            echo("  Rebuild: $rebuildAction -> ${rebuildHosts.joinToString(", ")}")
        }
        echo()

        // Step 1: Index the org directory
        echo("Step 1: Indexing org directory...")
        val config = computer.whatthefuck.arcology.indexer.IndexingConfig(
            batchSize = 50,
            memoryMonitoring = false,
            exportDatabaseAfterIndexing = false,
            orgDirectoryPath = expandedOrgDir,
            databasePath = databasePath
        )
        val indexer = computer.whatthefuck.arcology.indexer.createIndexingService(databasePath, config, expandedOrgDir)
        val reporter = computer.whatthefuck.arcology.cli.CliProgressReporter()
        try {
            runBlocking {
                indexer.indexDirectoryFlow(expandedOrgDir).collect { progress ->
                    when (progress) {
                        is computer.whatthefuck.arcology.indexer.IndexProgress.FileDiscovered -> {
                            reporter.tickDiscovery(progress.totalDiscovered)
                        }
                        is computer.whatthefuck.arcology.indexer.IndexProgress.Completed -> {
                            reporter.finish()
                            echo("  Indexed: ${progress.summary.successful} successful, ${progress.summary.failed} failed, ${progress.summary.skipped} skipped")
                        }
                        is computer.whatthefuck.arcology.indexer.IndexProgress.CriticalError -> {
                            reporter.finish()
                            echo("  Critical error: ${progress.error.message ?: progress.error.javaClass.simpleName}", err = true)
                        }
                        else -> { /* skip intermediate progress */ }
                    }
                }
            }
        } finally {
            reporter.finish()
        }

        // Step 2: Find files to tangle
        echo()
        val database = computer.whatthefuck.arcology.database.DatabaseFactory.createDatabase(databasePath)
        val repo = ArroyoRepositoryImpl(database)

        // In --all-modules mode the flood runs in two phases: module files first
        // (ARROYO_NIXOS_MODULE/HOME_MODULE/EMACS_MODULE), then ARROYO_TANGLE_THIS
        // files. Files carrying both a module keyword and ARROYO_TANGLE_THIS are
        // tangled in both phases — the TANGLE_THIS phase often generates
        // entry-point outputs (flake.nix, init.el) that consume the module
        // snippets the first phase just wrote to disk, so it must run last.
        data class TanglePhase(val label: String, val files: List<Pair<String, String?>>)

        val phases: List<TanglePhase> = if (allModules) {
            echo("Step 2: Finding all module files (ARROYO_NIXOS_MODULE, ARROYO_HOME_MODULE, ARROYO_EMACS_MODULE) and ARROYO_TANGLE_THIS files...")
            val nixosFiles = runBlocking { repo.getNixosModulesMeta(effectiveRole) }
            val homeFiles = runBlocking { repo.getHomeManagerModulesMeta(effectiveRole) }
            val emacsFiles = runBlocking { repo.getEmacsModulesMeta() }
            val tangleThisFiles = runBlocking { repo.getArroyoKeywords("ARROYO_TANGLE_THIS") }

            val moduleFileSet = linkedSetOf<String>()
            nixosFiles.forEach { moduleFileSet.add(it.file) }
            homeFiles.forEach { moduleFileSet.add(it.file) }
            emacsFiles.forEach { moduleFileSet.add(it.file) }

            val matchedTangleThis = tangleThisFiles.filter { (_, value) ->
                effectiveRole == null || matchesRole(value, effectiveRole)
            }.map { it.first to (it.second as String?) }

            listOf(
                TanglePhase("module snippets", moduleFileSet.toList().map { it to null }),
                TanglePhase("ARROYO_TANGLE_THIS", matchedTangleThis)
            )
        } else {
            echo("Step 2: Finding ARROYO_TANGLE_THIS files...")
            val allTangleFiles = runBlocking { repo.getArroyoKeywords("ARROYO_TANGLE_THIS") }

            if (allTangleFiles.isEmpty()) {
                echo("  No files with ARROYO_TANGLE_THIS found.")
                return
            }

            listOf(TanglePhase(
                "ARROYO_TANGLE_THIS",
                allTangleFiles.filter { (_, value) ->
                    effectiveRole == null || matchesRole(value, effectiveRole)
                }.map { it.first to (it.second as String?) }
            ))
        }

        val totalFiles = phases.sumOf { it.files.size }
        if (totalFiles == 0) {
            echo("  No files match role filter${if (effectiveRole != null) " '${effectiveRole}'" else ""}.")
            return
        }

        phases.forEach { phase ->
            echo("  ${phase.label}: ${phase.files.size} file(s)")
            if (verbose) {
                phase.files.forEach { (file, value) ->
                    if (value != null) {
                        echo("    $file (ARROYO_TANGLE_THIS: $value)")
                    } else {
                        echo("    $file")
                    }
                }
            }
        }

        if (dryRun) {
            echo()
            echo("Dry run complete. No files were modified.")
            if (rebuildAction != null && rebuildHosts.isNotEmpty()) {
                echo("  (Rebuild skipped in dry-run mode.)")
            }
            return
        }

        // Step 3: Tangle each file with eval context, phase by phase
        echo()
        echo("Step 3: Tangling files with eval context...")
        val evalContext = ArroyoScriptHost(repo, expandedOrgDir)
        var totalOutputFiles = 0
        var failedFiles = 0
        val tangleReporter = computer.whatthefuck.arcology.cli.CliProgressReporter()
        var tangleIndex = 0

        try {
            phases.forEach { phase ->
                if (phases.size > 1) echo("Phase: ${phase.label}")
                for ((file, _) in phase.files) {
                    tangleIndex++
                    tangleReporter.tickWork(tangleIndex, totalFiles, file)
                    val orgFilePath = java.io.File(expandedOrgDir, file)
                    if (!orgFilePath.exists()) {
                        tangleReporter.fail("  SKIP: $file (file not found)")
                        if (!ignoreErrors) { failedFiles++; continue }
                        continue
                    }

                    val orgContent = orgFilePath.readText()
                    val tangle = OrgTangle()
                    val fileReader: (String) -> String? = { fileName ->
                        val resolved = java.io.File(orgFilePath.parentFile, fileName)
                        if (resolved.exists()) resolved.readText() else null
                    }
                    val repoRoot = findRepoRoot(orgFilePath)
                    val orgFileForMarkers = if (repoRoot != null) {
                        repoRoot.toPath().relativize(orgFilePath.toPath()).toString()
                    } else {
                        file
                    }
                    val result = try {
                        tangle.tangle(orgContent, orgFileForMarkers, evalContext, fileReader)
                    } catch (e: TangleEvalException) {
                        tangleReporter.fail("  ERROR: ${e.message}")
                        if (ignoreErrors) {
                            tangleReporter.fail("  SKIP: $file (eval failed, --ignore-errors set)")
                            failedFiles++
                            continue
                        } else {
                            tangleReporter.finish()
                            echo("  Aborting flood; no further files will be tangled.", err = true)
                            echo("  Use --ignore-errors to continue past eval failures.", err = true)
                            kotlin.system.exitProcess(1)
                        }
                    }

                    if (result.warnings.isNotEmpty()) {
                        result.warnings.forEach { w ->
                            when (w) {
                                is TangleWarning.EvalError -> tangleReporter.fail("  WARN: ${w.blockName}: ${w.message}")
                                is TangleWarning.MissingNowebRef -> tangleReporter.fail("  WARN: Missing noweb ref '${w.name}' in '${w.inBlockName}'")
                                is TangleWarning.CircularNowebRef -> tangleReporter.fail("  WARN: Circular noweb ref: ${w.cycle.joinToString(" -> ")}")
                                is TangleWarning.UnresolvedTableRef -> tangleReporter.fail("  WARN: Unresolved table arg '${w.tableName}' in '${w.inBlockName}'")
                                is TangleWarning.MissingEvalContext -> tangleReporter.fail("  WARN: No eval context for '${w.blockName}'")
                            }
                        }
                    }

                    if (result.files.isEmpty()) {
                        if (verbose) tangleReporter.fail("  DONE: $file (no tangle targets)")
                        continue
                    }

                    if (verbose) tangleReporter.fail("  DEBUG: ${result.files.size} tangle output(s) for $file")
                    for ((relativePath, content) in result.files) {
                        val targetFile = if (relativePath.startsWith("/")) {
                            java.io.File(relativePath)
                        } else {
                            java.io.File(expandedOrgDir, relativePath)
                        }
                        targetFile.parentFile.mkdirs()
                        targetFile.writeText(content)
                        if (relativePath in result.executableFiles) {
                            try {
                                targetFile.setExecutable(true, true)
                            } catch (e: Exception) {
                                tangleReporter.fail("  WARN: Failed to chmod +x $targetFile: ${e.message}")
                            }
                        }
                        totalOutputFiles++
                    }
                    if (verbose) tangleReporter.fail("  DONE: $file (${result.files.size} file(s))")
                }
            }
        } finally {
            tangleReporter.finish()
        }

        echo()
        if (failedFiles > 0 && !ignoreErrors) {
            echo("Flood complete with $failedFiles failure(s). Tangled $totalOutputFiles file(s).", err = true)
            echo("Use --ignore-errors to continue on failure.", err = true)
            kotlin.system.exitProcess(1)
        } else {
            echo("Flood complete. Tangled $totalOutputFiles file(s).")
        }

        // Optional rebuild: update flake inputs and run nixos-rebuild for each host
        if (rebuildAction != null && rebuildHosts.isNotEmpty() && !dryRun) {
            echo()
            val nixDir = java.io.File(System.getProperty("user.home"), "nix")
            if (noUpdate) {
                echo("Step 4: Skipping nix flake update (--no-update)")
            } else {
                echo("Step 4: Updating flake inputs in $nixDir ...")
                val updateProcess = ProcessBuilder("nix", "flake", "update")
                    .directory(nixDir)
                    .inheritIO()
                    .start()
                val updateExit = updateProcess.waitFor()
                if (updateExit != 0) {
                    echo("  nix flake update failed (exit $updateExit)", err = true)
                    kotlin.system.exitProcess(1)
                }
            }

            rebuildHosts.forEachIndexed { i, host ->
                echo()
                echo("Step ${5 + i}: nixos-rebuild $rebuildAction on $host")
                val rebuildCmd = if (rebuildAction == "build") {
                    listOf("nixos-rebuild", "build", "--flake", "$nixDir#$host", "--no-build-output")
                } else {
                    listOf(
                        "nixos-rebuild", rebuildAction!!,
                        "--flake", "$nixDir#$host",
                        "--target-host=$host",
                        "--ask-sudo-password",
                        "--sudo"
                    )
                }
                echo("$ " + rebuildCmd.joinToString(" "))
                val rebuildProcess = ProcessBuilder(rebuildCmd)
                    .inheritIO()
                    .start()
                val rebuildExit = rebuildProcess.waitFor()
                if (rebuildExit != 0) {
                    echo("  nixos-rebuild failed on $host (exit $rebuildExit)", err = true)
                    kotlin.system.exitProcess(1)
                }
            }
            echo()
            echo("Rebuild complete for ${rebuildHosts.size} host(s).")
        }
    }

    private fun expandTilde(path: String): String {
        if (path.startsWith("~")) {
            val home = System.getProperty("user.home")
            return if (path == "~") home
            else if (path.startsWith("~/")) home + path.substring(1)
            else path
        }
        return path
    }

    private fun findRepoRoot(file: java.io.File): java.io.File? {
        val markers = setOf(".git", ".hg", ".svn", ".bzr", "_darcs")
        var dir = file.absoluteFile.parentFile
        while (dir != null) {
            if (markers.any { java.io.File(dir, it).exists() }) return dir
            dir = dir.parentFile
        }
        return null
    }
}

/**
 * Check if a role filter matches an ARROYO_TANGLE_THIS value.
 * Values are space-delimited tokens. "yes" matches any role.
 */
fun matchesRole(tangleThisValue: String, role: String): Boolean {
    val tokens = tangleThisValue.split(Regex("\\s+"))
    return tokens.contains(role) || tokens.contains("yes")
}

/**
 * Validate that --rebuild and --on are both specified or both absent.
 * Returns an error message string if invalid, null if OK.
 */
fun validateRebuildOptions(rebuildAction: String?, rebuildHosts: List<String>): String? {
    return when {
        rebuildAction != null && rebuildHosts.isEmpty() ->
            "--rebuild requires at least one --on"
        rebuildAction == null && rebuildHosts.isNotEmpty() ->
            "--on requires --rebuild"
        else -> null
    }
}

Tangle Targets

The noweb-composed blocks below pull together the named blocks defined above. Each #+name: block in prior sections carries only a domain fragment; these targets assemble them into complete files.

Schema

sql#+name: arroyo-schema:tangle ../src/commonMain/sqldelight/computer/whatthefuck/arcology/db/Arroyo.sq:noweb yes
<<arroyo-unified-ddl>>
<<arroyo-unified-queries>>

Repository

kotlin#+name: arroyo-repository:tangle ../src/commonMain/kotlin/computer/whatthefuck/arroyo/ArroyoRepository.kt:noweb yes
@file:OptIn(kotlin.time.ExperimentalTime::class)
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.db.ArcologyDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext

<<arroyo-repo-interface>>

class ArroyoRepositoryImpl(
    private val database: ArcologyDatabase
) : ArroyoRepository {

    @OptIn(ExperimentalCoroutinesApi::class)
    private val dbDispatcher = Dispatchers.IO.limitedParallelism(1)

<<arroyo-repo-unified-impl>>
}

Indexer Plugin

kotlin#+name: arroyo-indexer-plugin:tangle ../src/commonMain/kotlin/computer/whatthefuck/arroyo/ArroyoIndexerPlugin.kt:noweb yes
<<arroyo-plugin-header>>

<<arroyo-plugin-core>>
<<arroyo-plugin-extraction>>
}

Tests

Arroyo Indexing Test

Tests that the parser extracts ARROYO_ properties from both preamble and heading-level =#+ARROYO_= keyword lines, and that the indexer populates denormalized tables.

Note: ARROYO_ properties in heading sections use =#+ARROYO_ keyword lines (after :PROPERTIES:= drawer with :ID:), not :PROPERTIES: drawer entries. The orgmode-kmp parser captures keyword lines in section body as parsed chunks; the FlowFileIndexer extracts them from parseResult.document?.content body and stores them as nodeProperties.

kotlin#+name: arroyo-indexing-test:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arroyo/ArroyoIndexingTest.kt
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.RoamRepositoryImpl
import computer.whatthefuck.arcology.database.QuizRepositoryImpl
import computer.whatthefuck.arcology.indexer.*
import computer.whatthefuck.arcology.parser.OrgFileParser
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.*

class ArroyoIndexingTest {

    private val parser = OrgFileParser()

    private fun createFileSystem(vararg files: Pair<String, String>): FileSystemInterface {
        val fileMap = mutableMapOf(*files)
        return object : FileSystemInterface {
            override suspend fun fileExists(path: String) = fileMap.containsKey(path)
            override suspend fun readFile(path: String) = fileMap[path] ?: throw Exception("Not found: $path")
            override suspend fun readFileBytes(path: String) = readFile(path).encodeToByteArray()
            override suspend fun writeFile(path: String, content: String) { fileMap[path] = content }
            override suspend fun getLastModified(path: String) = kotlin.time.Instant.fromEpochSeconds(1640995200)
            override fun listOrgFiles(path: String, recursive: Boolean): Flow<String> =
                fileMap.keys.filter { it.startsWith(path) }.asFlow()
            override suspend fun readIgnoreFile(rootPath: String): String? = null
        }
    }

    @Test
    fun `extracts ARROYO_NIXOS_MODULE from preamble properties`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       test/home-manager
          :ARROYO_NIXOS_MODULE: nixos/home-manager.nix
          :ARROYO_SYSTEM_ROLE: endpoint
          :END:
          #+TITLE: Test Home Manager
        """.trimIndent()

        val filePath = "/test/home-manager.org"

        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success, "Parse should succeed: $parseResult")

        val result = parseResult as ParseResult.Success
        val nixosProps = result.fileProperties.filter { it.key == "ARROYO_NIXOS_MODULE" }
        assertTrue(nixosProps.isNotEmpty(), "Should extract ARROYO_NIXOS_MODULE from preamble")
        assertEquals("nixos/home-manager.nix", nixosProps.first().value)

        val roleProps = result.fileProperties.filter { it.key == "ARROYO_SYSTEM_ROLE" }
        assertTrue(roleProps.isNotEmpty(), "Should extract ARROYO_SYSTEM_ROLE from preamble")
    }

    @Test
    fun `extracts ARROYO_EMACS_MODULE and ARROYO_MODULE_WANTS from preamble`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       cce/evil_mode
          :ARROYO_EMACS_MODULE: evil-mode
          :ARROYO_MODULE_WANTS: cce/diminish,cce/configure_packaging
          :END:
          #+TITLE: Evil Mode
        """.trimIndent()

        val filePath = "/test/evil-mode.org"

        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success, "Parse should succeed: $parseResult")

        val result = parseResult as ParseResult.Success
        val emacsProps = result.fileProperties.filter { it.key == "ARROYO_EMACS_MODULE" }
        assertTrue(emacsProps.isNotEmpty(), "Should extract ARROYO_EMACS_MODULE")
        assertEquals("evil-mode", emacsProps.first().value)

        val wantsProps = result.fileProperties.filter { it.key == "ARROYO_MODULE_WANTS" }
        assertTrue(wantsProps.isNotEmpty(), "Should extract ARROYO_MODULE_WANTS")
    }

    @Test
    fun `extracts heading-level ARROYO properties from keyword lines`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       cce/kde-desktop
          :ARROYO_EMACS_MODULE: kde-desktop
          :END:
          #+TITLE: KDE Desktop
          
          * KDE NixOS Config
          :PROPERTIES:
          :ID:       d2af4461-e949-45ee-96a8-70501194188d
          :ARROYO_NIXOS_MODULE: nixos/kde.nix
          :ARROYO_HOME_MODULE: hm/kde.nix
          :ARROYO_SYSTEM_ROLE: endpoint
          :END:
          
        """.trimIndent()

        val filePath = "/test/kde-base.org"

        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success, "Parse should succeed: $parseResult")

        val result = parseResult as ParseResult.Success
        // ARROYO_* properties in heading :PROPERTIES: drawers are extracted as nodeProperties
        val headingNode = result.nodes.find { it.title == "KDE NixOS Config" }
        assertNotNull(headingNode, "Should extract heading node")
        assertEquals("d2af4461-e949-45ee-96a8-70501194188d", headingNode.id)

        val nodeProps = result.nodeProperties.filter { it.nodeId == "d2af4461-e949-45ee-96a8-70501194188d" }
        assertTrue(nodeProps.any { it.key == "ARROYO_NIXOS_MODULE" }, "Should extract ARROYO_NIXOS_MODULE from heading properties")
        assertTrue(nodeProps.any { it.key == "ARROYO_HOME_MODULE" }, "Should extract ARROYO_HOME_MODULE from heading properties")
        assertTrue(nodeProps.any { it.key == "ARROYO_SYSTEM_ROLE" }, "Should extract ARROYO_SYSTEM_ROLE from heading properties")
    }

    @Test
    fun `populates denormalized tables after indexing`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       test/home-manager
          :ARROYO_NIXOS_MODULE: nixos/home-manager.nix
          :ARROYO_HOME_MODULE: hm/home-manager.nix
          :ARROYO_EMACS_MODULE: home-manager
          :ARROYO_SYSTEM_ROLE: endpoint
          :ARROYO_TANGLE_THIS: yes
          :END:
          #+TITLE: Test Home Manager
        """.trimIndent()

        val filePath = "/test/home-manager.org"
        val fs = createFileSystem(filePath to orgContent)

        val database = DatabaseFactory.createInMemoryDatabase()
        val repository = RoamRepositoryImpl(database)
        val quizRepository = QuizRepositoryImpl(database)
        val arroyoRepository = ArroyoRepositoryImpl(database)

        val indexer = FlowFileIndexer(
            repository = repository,
            parser = parser,
            fileSystem = fs,
            config = IndexingConfig(
                batchSize = 10,
                enableFtsDefer = false
            ),
            plugins = listOf(ArroyoIndexerPlugin(arroyoRepository))
        )

        val result = indexer.indexFile(filePath)
        assertTrue(result is FileIndexResult.Success, "Expected Success, got ${result::class.simpleName}: $result")
        val success = result as FileIndexResult.Success

        val nixosModules = arroyoRepository.getNixosModules()
        assertTrue(nixosModules.contains("nixos/home-manager.nix"), "Should have NixOS module: $nixosModules")

        val homeModules = arroyoRepository.getHomeManagerModules()
        assertTrue(homeModules.contains("hm/home-manager.nix"), "Should have Home Manager module: $homeModules")

        // Module with ARROYO_SYSTEM_ROLE=endpoint should be visible for endpoint role
        val endpointNixos = arroyoRepository.getNixosModules("endpoint")
        assertTrue(endpointNixos.contains("nixos/home-manager.nix"), "Should be visible for endpoint role: $endpointNixos")

        // Module with explicit endpoint role should NOT be visible for unassigned roles
        val serverNixos = arroyoRepository.getNixosModules("server")
        assertFalse(serverNixos.contains("nixos/home-manager.nix"), "Should NOT be visible for server role: $serverNixos")

        val roles = arroyoRepository.getGeneratorRoles()
        assertTrue(roles.contains("endpoint"), "Should have endpoint role: $roles")

        val keywords = arroyoRepository.getArroyoKeywords("ARROYO_TANGLE_THIS")
        assertTrue(keywords.any { it.second == "yes" }, "Should have ARROYO_TANGLE_THIS keyword: $keywords")
    }

    @Test
    fun `extracts ARROYO_MODULE_WANTS from preamble keyword lines through full indexer`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       cce/evil_mode
          :END:
          #+TITLE: Evil Mode
          #+ARROYO_EMACS_MODULE: evil-mode
          #+ARROYO_MODULE_WANTS: cce/diminish
          #+ARROYO_MODULE_WANTS: cce/configure_packaging
        """.trimIndent()

        val filePath = "/test/evil-mode.org"
        val fs = createFileSystem(filePath to orgContent)

        val database = DatabaseFactory.createInMemoryDatabase()
        val repository = RoamRepositoryImpl(database)
        val quizRepository = QuizRepositoryImpl(database)
        val arroyoRepository = ArroyoRepositoryImpl(database)

        val indexer = FlowFileIndexer(
            repository = repository,
            parser = parser,
            fileSystem = fs,
            config = IndexingConfig(
                batchSize = 10,
                enableFtsDefer = false
            ),
            plugins = listOf(ArroyoIndexerPlugin(arroyoRepository))
        )

        val result = indexer.indexFile(filePath)
        assertTrue(result is FileIndexResult.Success, "Expected Success, got ${result::class.simpleName}: $result")

        val emacsModules = arroyoRepository.getEmacsModules()
        assertEquals(1, emacsModules.size, "Should have one emacs module")
        assertEquals("evil-mode", emacsModules.first())

        val wants = arroyoRepository.getArroyoKeywords("ARROYO_MODULE_WANTS")
        assertTrue(wants.any { it.second == "cce/diminish" }, "wants should contain cce/diminish: $wants")
        assertTrue(wants.any { it.second == "cce/configure_packaging" }, "wants should contain cce/configure_packaging: $wants")
    }
}

Arroyo Query Test

Direct insert into Arroyo tables via repository, then verify query methods and topological sort.

kotlin#+name: arroyo-query-test:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arroyo/ArroyoQueryTest.kt
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.database.DatabaseFactory
import kotlinx.coroutines.test.runTest
import kotlin.test.*

class ArroyoQueryTest {

    private suspend fun createRepo(): ArroyoRepository {
        val db = DatabaseFactory.createInMemoryDatabase()
        return ArroyoRepositoryImpl(db)
    }

    @Test
    fun `nixos modules by role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix")
        repo.insertGeneratorRole("a.org", null, "server")
        repo.insertArroyoKeyword("b.org", "ARROYO_NIXOS_MODULE", "nixos/b.nix")
        repo.insertGeneratorRole("b.org", null, "endpoint")
        repo.insertArroyoKeyword("c.org", "ARROYO_NIXOS_MODULE", "nixos/c.nix")
        repo.insertGeneratorRole("c.org", null, "server")

        val serverModules = repo.getNixosModules("server")
        assertEquals(2, serverModules.size)
        assertTrue(serverModules.contains("nixos/a.nix"))
        assertTrue(serverModules.contains("nixos/c.nix"))

        val allModules = repo.getNixosModules()
        assertEquals(3, allModules.size)
    }

    @Test
    fun `nixos modules with no roles applies to all`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix")
        // No generator role rows — applies to ALL roles

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(1, endpointModules.size)
        assertTrue(endpointModules.contains("nixos/a.nix"))
    }

    @Test
    fun `nixos modules with excluded role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix")
        // No roles = all roles, but excluded from waterboy
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_EXCLUDE", "waterboy")

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(1, endpointModules.size)
        assertTrue(endpointModules.contains("nixos/a.nix"))

        val waterboyModules = repo.getNixosModules("waterboy")
        assertEquals(0, waterboyModules.size)
    }

    @Test
    fun `nixos modules with both roles and exclusions`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertGeneratorRole("a.org", null, "server")
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_EXCLUDE", "waterboy")

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(1, endpointModules.size)

        val waterboyModules = repo.getNixosModules("waterboy")
        assertEquals(0, waterboyModules.size)
    }

    @Test
    fun `heading-scoped roles isolate modules within same file`() = runTest {
        val repo = createRepo()
        // Two modules in same file, each with different heading-scoped roles
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/endpoint.nix", "heading-ep")
        repo.insertGeneratorRole("a.org", "heading-ep", "endpoint")
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/server.nix", "heading-srv")
        repo.insertGeneratorRole("a.org", "heading-srv", "server")
        // Populate node_ancestors for closure table queries
        repo.insertNodeAncestor("heading-ep", "heading-ep")
        repo.insertNodeAncestor("heading-srv", "heading-srv")

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(1, endpointModules.size)
        assertTrue(endpointModules.contains("nixos/endpoint.nix"))
        assertFalse(endpointModules.contains("nixos/server.nix"))

        val serverModules = repo.getNixosModules("server")
        assertEquals(1, serverModules.size)
        assertTrue(serverModules.contains("nixos/server.nix"))
        assertFalse(serverModules.contains("nixos/endpoint.nix"))
    }

    @Test
    fun `heading-scoped exclusion isolates within same file`() = runTest {
        val repo = createRepo()
        // Two modules in same file, one excluded from waterboy
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/excluded.nix", "heading-ex")
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_EXCLUDE", "waterboy", "heading-ex")
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/clean.nix", "heading-cl")
        repo.insertNodeAncestor("heading-ex", "heading-ex")
        repo.insertNodeAncestor("heading-cl", "heading-cl")

        val waterboyModules = repo.getNixosModules("waterboy")
        assertEquals(1, waterboyModules.size)
        assertTrue(waterboyModules.contains("nixos/clean.nix"))
        assertFalse(waterboyModules.contains("nixos/excluded.nix"))

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(2, endpointModules.size)
        assertTrue(endpointModules.contains("nixos/excluded.nix"))
        assertTrue(endpointModules.contains("nixos/clean.nix"))
    }

    @Test
    fun `file-level role applies to all headings in file`() = runTest {
        val repo = createRepo()
        // File-level role (null node_id) should apply to all heading-scoped modules
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix", "heading-a")
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/b.nix", "heading-b")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertNodeAncestor("heading-a", "heading-a")
        repo.insertNodeAncestor("heading-b", "heading-b")

        val endpointModules = repo.getNixosModules("endpoint")
        assertEquals(2, endpointModules.size)
        assertTrue(endpointModules.contains("nixos/a.nix"))
        assertTrue(endpointModules.contains("nixos/b.nix"))
    }

    @Test
    fun `home manager modules by role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_HOME_MODULE", "hm/a.nix")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertArroyoKeyword("b.org", "ARROYO_HOME_MODULE", "hm/b.nix")
        repo.insertGeneratorRole("b.org", null, "server")

        val endpointModules = repo.getHomeManagerModules("endpoint")
        assertEquals(1, endpointModules.size)
        assertEquals("hm/a.nix", endpointModules.first())
    }

    @Test
    fun `emacs snippets with topological sort`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_EMACS_MODULE", "a.el", "id-a")
        repo.insertArroyoKeyword("b.org", "ARROYO_EMACS_MODULE", "b.el", "id-b")
        repo.insertArroyoKeyword("b.org", "ARROYO_MODULE_WANTS", "id-a", "id-b")
        repo.insertArroyoKeyword("c.org", "ARROYO_EMACS_MODULE", "c.el", "id-c")
        repo.insertArroyoKeyword("c.org", "ARROYO_MODULE_WANTS", "id-b", "id-c")

        val modules = repo.getEmacsModules()
        val allWants = repo.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTS")
        val wantsByNodeId = allWants.groupBy({ it.second ?: it.first }, { it.third })
        val emacsKeywords = repo.getArroyoKeywordsWithNodeId("ARROYO_EMACS_MODULE")
        val nodeIdToModule = emacsKeywords.associate { (it.second ?: it.first) to it.third }
        val moduleToNodeId = emacsKeywords.associate { it.third to (it.second ?: it.first) }

        val wantsMap = mutableMapOf<String, List<String>>()
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            val deps = wantsByNodeId[nodeId]?.mapNotNull { nodeIdToModule[it] }?.filter { it.isNotEmpty() } ?: emptyList()
            wantsMap[moduleFile] = deps
        }

        val sorted = topologicalSort(wantsMap)
        assertEquals(3, sorted.size)
        assertEquals("a.el", sorted[0], "a.el has no deps, should be first")
        assertEquals("b.el", sorted[1], "b.el wants a.el, should be second")
        assertEquals("c.el", sorted[2], "c.el wants b.el, should be third")
    }

    @Test
    fun `topological sort detects cycles`() = runTest {
        val cyclic = mapOf(
            "a" to listOf("b"),
            "b" to listOf("c"),
            "c" to listOf("a")
        )

        assertFailsWith<IllegalStateException> {
            topologicalSort(cyclic)
        }
    }

    @Test
    fun `topological sort respects wanted reverse edges`() = runTest {
        val repo = createRepo()
        // B has no deps, A has wanted: b.el meaning B depends on A
        repo.insertArroyoKeyword("a.org", "ARROYO_EMACS_MODULE", "a.el", "id-a")
        repo.insertArroyoKeyword("a.org", "ARROYO_MODULE_WANTED", "b.el", "id-a")
        repo.insertArroyoKeyword("b.org", "ARROYO_EMACS_MODULE", "b.el", "id-b")

        val modules = repo.getEmacsModules()
        val allWants = repo.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTS")
        val allWanted = repo.getArroyoKeywordsWithNodeId("ARROYO_MODULE_WANTED")
        val wantsByNodeId = allWants.groupBy({ it.second ?: it.first }, { it.third })
        val wantedByNodeId = allWanted.groupBy({ it.second ?: it.first }, { it.third })
        val emacsKeywords = repo.getArroyoKeywordsWithNodeId("ARROYO_EMACS_MODULE")
        val nodeIdToModule = emacsKeywords.associate { (it.second ?: it.first) to it.third }
        val moduleToNodeId = emacsKeywords.associate { it.third to (it.second ?: it.first) }

        val wantsMap = mutableMapOf<String, MutableList<String>>()
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            val deps = wantsByNodeId[nodeId]?.mapNotNull { nodeIdToModule[it] }?.filter { it.isNotEmpty() } ?: emptyList()
            wantsMap[moduleFile] = deps.toMutableList()
        }

        // Add reverse edges from wanted
        modules.forEach { moduleFile ->
            val nodeId = moduleToNodeId[moduleFile] ?: return@forEach
            wantedByNodeId[nodeId]?.mapNotNull { nodeIdToModule[it] }?.filter { it.isNotEmpty() }?.forEach { target ->
                if (target in wantsMap) {
                    wantsMap.getOrPut(target) { mutableListOf() }.add(moduleFile)
                }
            }
        }

        val sorted = topologicalSort(wantsMap)
        assertEquals(2, sorted.size)
        assertEquals("a.el", sorted[0], "a.el is wanted by b.el, so b.el depends on a.el, a.el should be first")
        assertEquals("b.el", sorted[1], "b.el should be second")
    }

    @Test
    fun `emacs epkgs`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_HOME_EPKGS", "{ pkgs }: [ pkgs.foo ]")
        repo.insertArroyoKeyword("b.org", "ARROYO_HOME_EPKGS", "{ pkgs }: [ pkgs.bar ]")

        val epkgs = repo.getEmacsEpkgs()
        assertEquals(2, epkgs.size)
        assertTrue(epkgs.any { it.contains("pkgs.foo") })
    }

    @Test
    fun `system overlays by role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_OVERLAY", "overlay.nix")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertArroyoKeyword("b.org", "ARROYO_SYSTEM_OVERLAY", "emacs-overlay.nix")
        repo.insertGeneratorRole("b.org", null, "server")

        val endpointOverlays = repo.getSystemOverlays("endpoint")
        assertEquals(1, endpointOverlays.size)
        assertTrue(endpointOverlays.contains("overlay.nix"))

        val allOverlays = repo.getSystemOverlays()
        assertEquals(2, allOverlays.size)
    }

    @Test
    fun `system overlays with no roles applies to all`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_OVERLAY", "overlay.nix")

        val endpointOverlays = repo.getSystemOverlays("endpoint")
        assertEquals(1, endpointOverlays.size)
        assertTrue(endpointOverlays.contains("overlay.nix"))
    }

    @Test
    fun `system overlays with excluded role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_OVERLAY", "overlay.nix")
        repo.insertArroyoKeyword("a.org", "ARROYO_SYSTEM_EXCLUDE", "waterboy")

        val endpointOverlays = repo.getSystemOverlays("endpoint")
        assertEquals(1, endpointOverlays.size)

        val waterboyOverlays = repo.getSystemOverlays("waterboy")
        assertEquals(0, waterboyOverlays.size)
    }

    @Test
    fun `inputs by role`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_INPUT", "versions.nix")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertArroyoKeyword("b.org", "ARROYO_INPUT", "lib/default.nix")
        repo.insertGeneratorRole("b.org", null, "server")

        val endpointInputs = repo.getInputs("endpoint")
        assertEquals(1, endpointInputs.size)
        assertTrue(endpointInputs.contains("versions.nix"))

        val allInputs = repo.getInputs()
        assertEquals(2, allInputs.size)
    }

    @Test
    fun `outputs`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_OUTPUT", "outputs/rose-quine.nix")
        repo.insertArroyoKeyword("b.org", "ARROYO_OUTPUT", "outputs/last-bank.nix")

        val outputs = repo.getOutputs()
        assertEquals(2, outputs.size)
        assertTrue(outputs.contains("outputs/rose-quine.nix"))
        assertTrue(outputs.contains("outputs/last-bank.nix"))
    }

    @Test
    fun `generator roles`() = runTest {
        val repo = createRepo()
        repo.insertGeneratorRole("a.org", "id-a", "endpoint")
        repo.insertGeneratorRole("b.org", "id-b", "server")
        repo.insertGeneratorRole("c.org", "id-c", "endpoint")

        val roles = repo.getGeneratorRoles()
        assertEquals(2, roles.size)
        assertTrue(roles.contains("endpoint"))
        assertTrue(roles.contains("server"))
    }

    @Test
    fun `arroyo keywords`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_TANGLE_THIS", "yes")
        repo.insertArroyoKeyword("b.org", "ARROYO_TANGLE_THIS", "endpoint")
        repo.insertArroyoKeyword("c.org", "ARROYO_SYSTEM_EXCLUDE", "waterboy")

        val tangleResults = repo.getArroyoKeywords("ARROYO_TANGLE_THIS")
        assertEquals(2, tangleResults.size)

        val excludeResults = repo.getArroyoKeywords("ARROYO_SYSTEM_EXCLUDE")
        assertEquals(1, excludeResults.size)
        assertEquals("waterboy", excludeResults.first().second)
    }

    @Test
    fun `delete all by file`() = runTest {
        val repo = createRepo()
        repo.insertArroyoKeyword("a.org", "ARROYO_NIXOS_MODULE", "nixos/a.nix")
        repo.insertGeneratorRole("a.org", null, "server")
        repo.insertArroyoKeyword("a.org", "ARROYO_HOME_MODULE", "hm/a.nix")
        repo.insertGeneratorRole("a.org", null, "endpoint")
        repo.insertArroyoKeyword("a.org", "ARROYO_TANGLE_THIS", "yes")

        repo.deleteAllByFile("a.org")

        assertEquals(0, repo.getNixosModules().size)
        assertEquals(0, repo.getHomeManagerModules().size)
        assertEquals(0, repo.getArroyoKeywords("ARROYO_TANGLE_THIS").size)
    }
}

Real Arroyo Integration Test

Indexes real org files from the context/ directory and verifies denormalized tables are populated. Tagged integration.

kotlin#+name: arroyo-integration-test:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arroyo/RealArroyoIntegrationTest.kt
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.DatabaseTestUtils
import computer.whatthefuck.arcology.indexer.*
import computer.whatthefuck.arcology.parser.OrgFileParser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Tag
import java.io.File
import kotlin.test.*

@Tag("integration")
class RealArroyoIntegrationTest {

    private val contextDir = File("context")
    private val fileSystem = JvmFileSystem()
    private val parser = OrgFileParser()

    private fun assumeContextDirExists() {
        if (!contextDir.exists() || !contextDir.isDirectory) {
            println("Skipping test: context/ directory does not exist")
        }
    }

    @Test
    fun `parses context org files and extracts ARROYO properties`() = runBlocking {
        assumeContextDirExists()
        if (!contextDir.exists()) return@runBlocking

        val contextFiles = contextDir.listFiles { f -> f.name.endsWith(".org") }?.toList() ?: emptyList()
        assertTrue(contextFiles.isNotEmpty(), "Should find .org files in context/")

        var foundArroyoProps = false

        for (file in contextFiles) {
            val content = fileSystem.readFile(file.absolutePath)
            val lastModified = fileSystem.getLastModified(file.absolutePath)
            val result = parser.parseFileContent(file.absolutePath, content, lastModified)

            if (result is computer.whatthefuck.arcology.parser.ParseResult.Success) {
                val arroyoProps = result.fileProperties.filter { it.key.startsWith("ARROYO_") }
                if (arroyoProps.isNotEmpty()) {
                    foundArroyoProps = true
                    println("${file.name}: ${arroyoProps.map { "${it.key}=${it.value}" }}")
                }
                val headingArroyo = result.nodeProperties.filter { it.key.startsWith("ARROYO_") }
                if (headingArroyo.isNotEmpty()) {
                    foundArroyoProps = true
                    println("${file.name} (heading): ${headingArroyo.map { "${it.key}=${it.value}" }}")
                }
            }
        }

        assertTrue(foundArroyoProps, "Should find ARROYO_* properties in at least one context file")
    }

    @Test
    fun `indexes context files and populates Arroyo tables`() = runBlocking {
        assumeContextDirExists()
        if (!contextDir.exists()) return@runBlocking

        val contextFiles = contextDir.listFiles { f -> f.name.endsWith(".org") }?.toList() ?: emptyList()
        assertTrue(contextFiles.isNotEmpty(), "Should find .org files in context/")

        val repository = DatabaseTestUtils.createTestRepository()
        val quizRepository = DatabaseTestUtils.createTestQuizRepository()
        val arroyoRepo = ArroyoRepositoryImpl(DatabaseFactory.createInMemoryDatabase())

        val limitedFileSystem = object : FileSystemInterface {
            override suspend fun fileExists(path: String) = fileSystem.fileExists(path)
            override suspend fun readFile(path: String) = fileSystem.readFile(path)
            override suspend fun readFileBytes(path: String) = fileSystem.readFileBytes(path)
            override suspend fun writeFile(path: String, content: String) = fileSystem.writeFile(path, content)
            override suspend fun getLastModified(path: String) = fileSystem.getLastModified(path)
            override fun listOrgFiles(path: String, recursive: Boolean): Flow<String> = contextFiles.map { it.absolutePath }.asFlow()
            override suspend fun readIgnoreFile(rootPath: String): String? = null
        }

        val indexer = FlowFileIndexer(
            repository = repository,
            parser = parser,
            fileSystem = limitedFileSystem,
            config = IndexingConfig(
                batchSize = 10,
                enableFtsDefer = false
            ),
            plugins = listOf(ArroyoIndexerPlugin(arroyoRepo))
        )

        val events = indexer.indexDirectoryFlow(contextDir.absolutePath).toList()
        val completed = events.filterIsInstance<IndexProgress.Completed>().lastOrNull()
        assertNotNull(completed, "Should complete indexing")

        val nixosModules = arroyoRepo.getNixosModules()
        val homeModules = arroyoRepo.getHomeManagerModules()
        val emacsModules = arroyoRepo.getEmacsModules()
        val roles = arroyoRepo.getGeneratorRoles()
        val keywords = arroyoRepo.getArroyoKeywords("ARROYO_TANGLE_THIS")

        println("NixOS modules: $nixosModules")
        println("Home Manager modules: $homeModules")
        println("Emacs modules: $emacsModules")
        println("Roles: $roles")
        println("ARROYO_TANGLE_THIS: $keywords")

        // home-manager.org has ARROYO_NIXOS_MODULE: nixos/home-manager.nix
        assertTrue(nixosModules.any { it.contains("home-manager") }, "Should find home-manager NixOS module")

        // home-manager.org has ARROYO_EMACS_MODULE: home-manager
        assertTrue(emacsModules.any { it == "home-manager" }, "Should find home-manager emacs module")

        // home-manager.org has ARROYO_TANGLE_THIS: endpoint server droid
        assertTrue(keywords.isNotEmpty(), "Should find ARROYO_TANGLE_THIS keywords")
    }

    @Test
    fun `topologically sorts emacs snippets from context files`() = runBlocking {
        assumeContextDirExists()
        if (!contextDir.exists()) return@runBlocking

        val contextFiles = contextDir.listFiles { f -> f.name.endsWith(".org") }?.toList() ?: emptyList()
        val repository = DatabaseTestUtils.createTestRepository()
        val quizRepository = DatabaseTestUtils.createTestQuizRepository()
        val arroyoRepo = ArroyoRepositoryImpl(DatabaseFactory.createInMemoryDatabase())

        val limitedFileSystem = object : FileSystemInterface {
            override suspend fun fileExists(path: String) = fileSystem.fileExists(path)
            override suspend fun readFile(path: String) = fileSystem.readFile(path)
            override suspend fun readFileBytes(path: String) = fileSystem.readFileBytes(path)
            override suspend fun writeFile(path: String, content: String) = fileSystem.writeFile(path, content)
            override suspend fun getLastModified(path: String) = fileSystem.getLastModified(path)
            override fun listOrgFiles(path: String, recursive: Boolean): Flow<String> = contextFiles.map { it.absolutePath }.asFlow()
            override suspend fun readIgnoreFile(rootPath: String): String? = null
        }

        val indexer = FlowFileIndexer(
            repository = repository,
            parser = parser,
            fileSystem = limitedFileSystem,
            config = IndexingConfig(
                batchSize = 10,
                enableFtsDefer = false
            ),
            plugins = listOf(ArroyoIndexerPlugin(arroyoRepo))
        )

        indexer.indexDirectoryFlow(contextDir.absolutePath).toList()

        val modules = arroyoRepo.getEmacsModules()
        if (modules.size >= 2) {
            val allWants = arroyoRepo.getArroyoKeywords("ARROYO_MODULE_WANTS")
            val wantsByFile = allWants.groupBy({ it.first }, { it.second })
            val emacsKeywords = arroyoRepo.getArroyoKeywords("ARROYO_EMACS_MODULE")
            val moduleToFile = emacsKeywords.associate { it.second to it.first }

            val wantsMap = mutableMapOf<String, List<String>>()
            modules.forEach { moduleFile ->
                val file = moduleToFile[moduleFile] ?: return@forEach
                wantsMap[moduleFile] = wantsByFile[file] ?: emptyList()
            }
            val sorted = topologicalSort(wantsMap)
            println("Topologically sorted snippets: $sorted")
            assertEquals(modules.size, sorted.size, "All snippets should be sortable")
        } else {
            println("Only ${modules.size} snippet(s) found, skipping topological sort test")
        }
    }
}

Arroyo Indexer Plugin Test

Tests that ArroyoIndexerPlugin correctly extracts ARROYO_* keywords from parsed files and populates denormalized tables. Unlike ArroyoIndexingTest which tests through the full FlowFileIndexer, this test exercises the plugin in isolation.

kotlin#+name: arroyo-indexer-plugin-test:tangle ../src/jvmTest/kotlin/computer/whatthefuck/arroyo/ArroyoIndexerPluginTest.kt
package computer.whatthefuck.arroyo

import computer.whatthefuck.arcology.database.DatabaseFactory
import computer.whatthefuck.arcology.database.RoamRepositoryImpl
import computer.whatthefuck.arcology.indexer.IndexerPlugin
import computer.whatthefuck.arcology.parser.OrgFileParser
import computer.whatthefuck.arcology.parser.ParseResult
import kotlinx.coroutines.test.runTest
import kotlin.test.*

class ArroyoIndexerPluginTest {

    private val parser = OrgFileParser()

    @Test
    fun `extracts ARROYO_NIXOS_MODULE from preamble properties`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/home-manager
:ARROYO_NIXOS_MODULE: nixos/home-manager.nix
:ARROYO_SYSTEM_ROLE: endpoint
:END:
#+TITLE: Test Home Manager
        """.trimIndent()

        val filePath = "/test/home-manager.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin: IndexerPlugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        val modules = arroyoRepo.getNixosModules()
        assertTrue(modules.contains("nixos/home-manager.nix"))

        // Module has ARROYO_SYSTEM_ROLE=endpoint, so visible for endpoint
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertTrue(endpointModules.contains("nixos/home-manager.nix"))

        // Module has roles, so NOT visible for unassigned roles
        val serverModules = arroyoRepo.getNixosModules("server")
        assertFalse(serverModules.contains("nixos/home-manager.nix"))
    }

    @Test
    fun `module without roles applies to all roles`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/no-roles
:ARROYO_NIXOS_MODULE: nixos/no-roles.nix
:END:
#+TITLE: No Roles
        """.trimIndent()

        val filePath = "/test/no-roles.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // No ARROYO_SYSTEM_ROLE means applies to ALL roles
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertTrue(endpointModules.contains("nixos/no-roles.nix"))

        val serverModules = arroyoRepo.getNixosModules("server")
        assertTrue(serverModules.contains("nixos/no-roles.nix"))
    }

    @Test
    fun `module with excluded role is hidden from that role`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/excluded
:ARROYO_NIXOS_MODULE: nixos/excluded.nix
:ARROYO_SYSTEM_EXCLUDE: waterboy
:END:
#+TITLE: Excluded
        """.trimIndent()

        val filePath = "/test/excluded.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // No roles = all roles, but excluded from waterboy
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertTrue(endpointModules.contains("nixos/excluded.nix"))

        val waterboyModules = arroyoRepo.getNixosModules("waterboy")
        assertFalse(waterboyModules.contains("nixos/excluded.nix"))
    }

    @Test
    fun `extracts heading-level ARROYO keywords from section bodies`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       test/kde-desktop
          :END:
          #+TITLE: KDE Desktop
          
          ,* KDE NixOS Config
          :PROPERTIES:
          :ID:       d2af4461-e949-45ee-96a8-70501194188d
          :ARROYO_NIXOS_MODULE: nixos/kde.nix
          :ARROYO_HOME_MODULE: hm/kde.nix
          :END:
          
        """.trimIndent()

        val filePath = "/test/kde.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        val nixos = arroyoRepo.getNixosModules()
        assertTrue(nixos.contains("nixos/kde.nix"))

        val home = arroyoRepo.getHomeManagerModules()
        assertTrue(home.contains("hm/kde.nix"))
    }

    @Test
    fun `handles preamble keyword lines`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/preamble
:END:
#+TITLE: Preamble Keywords
#+ARROYO_TANGLE_THIS: endpoint
#+ARROYO_SYSTEM_EXCLUDE: waterboy
        """.trimIndent()

        val filePath = "/test/preamble.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        val tangleKeywords = arroyoRepo.getArroyoKeywords("ARROYO_TANGLE_THIS")
        assertTrue(tangleKeywords.any { it.second == "endpoint" })

        val excludeKeywords = arroyoRepo.getArroyoKeywords("ARROYO_SYSTEM_EXCLUDE")
        assertTrue(excludeKeywords.any { it.second == "waterboy" })
    }

    @Test
    fun `heading-scoped role applies only to that heading's modules`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       test/multi-module
          :END:
          #+TITLE: Multi Module File
          
          ,* Endpoint Module
          :PROPERTIES:
          :ID:       heading-endpoint
          :ARROYO_NIXOS_MODULE: nixos/endpoint-only.nix
          :ARROYO_SYSTEM_ROLE: endpoint
          :END:
          
          ,* Server Module
          :PROPERTIES:
          :ID:       heading-server
          :ARROYO_NIXOS_MODULE: nixos/server-only.nix
          :ARROYO_SYSTEM_ROLE: server
          :END:
          
        """.trimIndent()

        val filePath = "/test/multi-module.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // Both modules should exist
        val allModules = arroyoRepo.getNixosModules()
        assertTrue(allModules.contains("nixos/endpoint-only.nix"))
        assertTrue(allModules.contains("nixos/server-only.nix"))

        // Endpoint role should only see the endpoint module
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertTrue(endpointModules.contains("nixos/endpoint-only.nix"))
        assertFalse(endpointModules.contains("nixos/server-only.nix"))

        // Server role should only see the server module
        val serverModules = arroyoRepo.getNixosModules("server")
        assertTrue(serverModules.contains("nixos/server-only.nix"))
        assertFalse(serverModules.contains("nixos/endpoint-only.nix"))
    }

    @Test
    fun `heading-scoped exclusion hides only that heading's modules`() = runTest {
        val orgContent = """
          :PROPERTIES:
          :ID:       test/multi-exclude
          :END:
          #+TITLE: Multi Module With Exclusions
          
          ,* Excluded from waterboy
          :PROPERTIES:
          :ID:       heading-excluded
          :ARROYO_NIXOS_MODULE: nixos/excluded.nix
          :ARROYO_SYSTEM_EXCLUDE: waterboy
          :END:
          
          ,* No exclusion
          :PROPERTIES:
          :ID:       heading-clean
          :ARROYO_NIXOS_MODULE: nixos/clean.nix
          :END:
          
        """.trimIndent()

        val filePath = "/test/multi-exclude.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // Both modules should exist
        val allModules = arroyoRepo.getNixosModules()
        assertTrue(allModules.contains("nixos/excluded.nix"))
        assertTrue(allModules.contains("nixos/clean.nix"))

        // waterboy should see clean but not excluded
        val waterboyModules = arroyoRepo.getNixosModules("waterboy")
        assertTrue(waterboyModules.contains("nixos/clean.nix"))
        assertFalse(waterboyModules.contains("nixos/excluded.nix"))

        // endpoint should see both (no exclusions for endpoint)
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertTrue(endpointModules.contains("nixos/excluded.nix"))
        assertTrue(endpointModules.contains("nixos/clean.nix"))
    }

    @Test
    fun `multi-valued system roles split on whitespace in properties drawer`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/multi-role
:ARROYO_NIXOS_MODULE: nixos/nginx-base.nix
:ARROYO_SYSTEM_ROLE: edge server
:END:
#+TITLE: Multi Role
        """.trimIndent()

        val filePath = "/test/multi-role.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // Module should be visible for both roles
        val edgeModules = arroyoRepo.getNixosModules("edge")
        assertTrue(edgeModules.contains("nixos/nginx-base.nix"))

        val serverModules = arroyoRepo.getNixosModules("server")
        assertTrue(serverModules.contains("nixos/nginx-base.nix"))

        // Module should NOT be visible for unassigned roles
        val endpointModules = arroyoRepo.getNixosModules("endpoint")
        assertFalse(endpointModules.contains("nixos/nginx-base.nix"))
    }

    @Test
    fun `multi-valued nixos modules split on whitespace in properties drawer`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/multi-module-prop
:ARROYO_NIXOS_MODULE: nixos/acme.nix nixos/certs.nix
:END:
#+TITLE: Multi Module
        """.trimIndent()

        val filePath = "/test/multi-module-prop.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        val allModules = arroyoRepo.getNixosModules()
        assertTrue(allModules.contains("nixos/acme.nix"))
        assertTrue(allModules.contains("nixos/certs.nix"))
    }

    @Test
    fun `multi-valued system exclude split on whitespace in properties drawer`() = runTest {
        val orgContent = """
:PROPERTIES:
:ID:       test/multi-exclude-prop
:ARROYO_NIXOS_MODULE: nixos/syncthing.nix
:ARROYO_SYSTEM_EXCLUDE: waterboy edge
:END:
#+TITLE: Multi Exclude
        """.trimIndent()

        val filePath = "/test/multi-exclude-prop.org"
        val parseResult = parser.parseFileContent(filePath, orgContent, kotlin.time.Instant.fromEpochSeconds(1640995200))
        assertTrue(parseResult is ParseResult.Success)

        val database = DatabaseFactory.createInMemoryDatabase()
        val arroyoRepo = ArroyoRepositoryImpl(database)
        val plugin = ArroyoIndexerPlugin(arroyoRepo)

        plugin.onFileIndexed(parseResult as ParseResult.Success)

        // No roles = all roles, but excluded from waterboy and edge
        val waterboyModules = arroyoRepo.getNixosModules("waterboy")
        assertFalse(waterboyModules.contains("nixos/syncthing.nix"))

        val edgeModules = arroyoRepo.getNixosModules("edge")
        assertFalse(edgeModules.contains("nixos/syncthing.nix"))

        // server is not excluded
        val serverModules = arroyoRepo.getNixosModules("server")
        assertTrue(serverModules.contains("nixos/syncthing.nix"))
    }
}