/** * Collections configuration management * * This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml. * Collections define which directories to index and their associated contexts. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join, dirname } from "path"; import { homedir } from "os"; import YAML from "yaml"; // ============================================================================ // Types // ============================================================================ /** * Context definitions for a collection * Key is path prefix (e.g., "/", "/2024", "/Board of Directors") * Value is the context description */ export type ContextMap = Record; /** * A single collection configuration */ export interface Collection { path: string; // Absolute path to index pattern: string; // Glob pattern (e.g., "**/*.md") ignore?: string[]; // Glob patterns to exclude (e.g., ["Sessions/**"]) context?: ContextMap; // Optional context definitions update?: string; // Optional bash command to run during qmd update includeByDefault?: boolean; // Include in queries by default (default: true) } /** * Model configuration for embedding, reranking, and generation */ export interface ModelsConfig { embed?: string; rerank?: string; generate?: string; } /** * The complete configuration file structure */ export interface CollectionConfig { global_context?: string; // Context applied to all collections editor_uri?: string; // Editor URI template for terminal hyperlinks editor_uri_template?: string; // Alias for editor_uri collections: Record; // Collection name -> config models?: ModelsConfig; } /** * Collection with its name (for return values) */ export interface NamedCollection extends Collection { name: string; } // ============================================================================ // Configuration paths // ============================================================================ // Current index name (default: "index") let currentIndexName: string = "index"; // SDK mode: optional in-memory config or custom config path let configSource: { type: 'file'; path?: string } | { type: 'inline'; config: CollectionConfig } = { type: 'file' }; /** * Set the config source for SDK mode. * - File path: load/save from a specific YAML file * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O) * - undefined: reset to default file-based config */ export function setConfigSource(source?: { configPath?: string; config?: CollectionConfig }): void { if (!source) { configSource = { type: 'file' }; return; } if (source.config) { // Ensure collections object exists if (!source.config.collections) { source.config.collections = {}; } configSource = { type: 'inline', config: source.config }; } else if (source.configPath) { configSource = { type: 'file', path: source.configPath }; } else { configSource = { type: 'file' }; } } /** * Set the current index name for config file lookup * Config file will be ~/.config/qmd/{indexName}.yml */ export function setConfigIndexName(name: string): void { // Resolve relative paths to absolute paths and sanitize for use as filename if (name.includes('/')) { const { resolve } = require('path'); const { cwd } = require('process'); const absolutePath = resolve(cwd(), name); // Replace path separators with underscores to create a valid filename currentIndexName = absolutePath.replace(/\//g, '_').replace(/^_/, ''); } else { currentIndexName = name; } } function getConfigDir(): string { // Allow override via QMD_CONFIG_DIR for testing if (process.env.QMD_CONFIG_DIR) { return process.env.QMD_CONFIG_DIR; } // Respect XDG Base Directory specification (consistent with store.ts) if (process.env.XDG_CONFIG_HOME) { return join(process.env.XDG_CONFIG_HOME, "qmd"); } return join(homedir(), ".config", "qmd"); } function getConfigFilePath(): string { return join(getConfigDir(), `${currentIndexName}.yml`); } /** * Ensure config directory exists */ function ensureConfigDir(): void { const configDir = getConfigDir(); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } } // ============================================================================ // Core functions // ============================================================================ /** * Load configuration from the configured source. * - Inline config: returns the in-memory object directly * - File-based: reads from YAML file (default ~/.config/qmd/index.yml) * Returns empty config if file doesn't exist */ export function loadConfig(): CollectionConfig { // SDK inline config mode if (configSource.type === 'inline') { return configSource.config; } // File-based config (SDK custom path or default) const configPath = configSource.path || getConfigFilePath(); if (!existsSync(configPath)) { return { collections: {} }; } try { const content = readFileSync(configPath, "utf-8"); const config = YAML.parse(content) as CollectionConfig; // Ensure collections object exists if (!config.collections) { config.collections = {}; } return config; } catch (error) { throw new Error(`Failed to parse ${configPath}: ${error}`); } } /** * Save configuration to the configured source. * - Inline config: updates the in-memory object (no file I/O) * - File-based: writes to YAML file (default ~/.config/qmd/index.yml) */ export function saveConfig(config: CollectionConfig): void { // SDK inline config mode: update in place, no file I/O if (configSource.type === 'inline') { configSource.config = config; return; } const configPath = configSource.path || getConfigFilePath(); const configDir = dirname(configPath); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } try { const yaml = YAML.stringify(config, { indent: 2, lineWidth: 0, // Don't wrap lines }); writeFileSync(configPath, yaml, "utf-8"); } catch (error) { throw new Error(`Failed to write ${configPath}: ${error}`); } } /** * Get a specific collection by name * Returns null if not found */ export function getCollection(name: string): NamedCollection | null { const config = loadConfig(); const collection = config.collections[name]; if (!collection) { return null; } return { name, ...collection }; } /** * List all collections */ export function listCollections(): NamedCollection[] { const config = loadConfig(); return Object.entries(config.collections).map(([name, collection]) => ({ name, ...collection, })); } /** * Get collections that are included by default in queries */ export function getDefaultCollections(): NamedCollection[] { return listCollections().filter(c => c.includeByDefault !== false); } /** * Get collection names that are included by default */ export function getDefaultCollectionNames(): string[] { return getDefaultCollections().map(c => c.name); } /** * Update a collection's settings */ export function updateCollectionSettings( name: string, settings: { update?: string | null; includeByDefault?: boolean } ): boolean { const config = loadConfig(); const collection = config.collections[name]; if (!collection) return false; if (settings.update !== undefined) { if (settings.update === null) { delete collection.update; } else { collection.update = settings.update; } } if (settings.includeByDefault !== undefined) { if (settings.includeByDefault === true) { // true is default, remove the field delete collection.includeByDefault; } else { collection.includeByDefault = settings.includeByDefault; } } saveConfig(config); return true; } /** * Add or update a collection */ export function addCollection( name: string, path: string, pattern: string = "**/*.md" ): void { const config = loadConfig(); config.collections[name] = { path, pattern, context: config.collections[name]?.context, // Preserve existing context }; saveConfig(config); } /** * Remove a collection */ export function removeCollection(name: string): boolean { const config = loadConfig(); if (!config.collections[name]) { return false; } delete config.collections[name]; saveConfig(config); return true; } /** * Rename a collection */ export function renameCollection(oldName: string, newName: string): boolean { const config = loadConfig(); if (!config.collections[oldName]) { return false; } if (config.collections[newName]) { throw new Error(`Collection '${newName}' already exists`); } config.collections[newName] = config.collections[oldName]; delete config.collections[oldName]; saveConfig(config); return true; } // ============================================================================ // Context management // ============================================================================ /** * Get global context */ export function getGlobalContext(): string | undefined { const config = loadConfig(); return config.global_context; } /** * Set global context */ export function setGlobalContext(context: string | undefined): void { const config = loadConfig(); config.global_context = context; saveConfig(config); } /** * Get all contexts for a collection */ export function getContexts(collectionName: string): ContextMap | undefined { const collection = getCollection(collectionName); return collection?.context; } /** * Add or update a context for a specific path in a collection */ export function addContext( collectionName: string, pathPrefix: string, contextText: string ): boolean { const config = loadConfig(); const collection = config.collections[collectionName]; if (!collection) { return false; } if (!collection.context) { collection.context = {}; } collection.context[pathPrefix] = contextText; saveConfig(config); return true; } /** * Remove a context from a collection */ export function removeContext( collectionName: string, pathPrefix: string ): boolean { const config = loadConfig(); const collection = config.collections[collectionName]; if (!collection?.context?.[pathPrefix]) { return false; } delete collection.context[pathPrefix]; // Remove empty context object if (Object.keys(collection.context).length === 0) { delete collection.context; } saveConfig(config); return true; } /** * List all contexts across all collections */ export function listAllContexts(): Array<{ collection: string; path: string; context: string; }> { const config = loadConfig(); const results: Array<{ collection: string; path: string; context: string }> = []; // Add global context if present if (config.global_context) { results.push({ collection: "*", path: "/", context: config.global_context, }); } // Add collection contexts for (const [name, collection] of Object.entries(config.collections)) { if (collection.context) { for (const [path, context] of Object.entries(collection.context)) { results.push({ collection: name, path, context, }); } } } return results; } /** * Find best matching context for a given collection and path * Returns the most specific matching context (longest path prefix match) */ export function findContextForPath( collectionName: string, filePath: string ): string | undefined { const config = loadConfig(); const collection = config.collections[collectionName]; if (!collection?.context) { return config.global_context; } // Find all matching prefixes const matches: Array<{ prefix: string; context: string }> = []; for (const [prefix, context] of Object.entries(collection.context)) { // Normalize paths for comparison const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`; const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`; if (normalizedPath.startsWith(normalizedPrefix)) { matches.push({ prefix: normalizedPrefix, context }); } } // Return most specific match (longest prefix) if (matches.length > 0) { matches.sort((a, b) => b.prefix.length - a.prefix.length); return matches[0]!.context; } // Fallback to global context return config.global_context; } // ============================================================================ // Utility functions // ============================================================================ /** * Get the config file path (useful for error messages) */ export function getConfigPath(): string { if (configSource.type === 'inline') return ''; return configSource.path || getConfigFilePath(); } /** * Check if config file exists */ export function configExists(): boolean { if (configSource.type === 'inline') return true; const path = configSource.path || getConfigFilePath(); return existsSync(path); } /** * Validate a collection name * Collection names must be valid and not contain special characters */ export function isValidCollectionName(name: string): boolean { // Allow alphanumeric, hyphens, underscores return /^[a-zA-Z0-9_-]+$/.test(name); }