From 91e5133e13895b7b41e7a72f6eea75064164ddfd Mon Sep 17 00:00:00 2001 From: reugenio Date: Wed, 17 Dec 2025 13:29:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ConfigManager=20-=20gestor=20aut=C3=B3n?= =?UTF-8?q?omo=20de=20configuraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade ConfigManager que maneja automáticamente: - Carga/creación de archivo config (loadOrCreate) - Auto-guardado en deinit si hay cambios pendientes - Sistema de observers con contexto para sincronización externa Cambios: - ConfigManager(variables, ConfigType, app_name) type - Observer con contexto: fn(change, config, ctx) void - addObserver(callback, context) para registrar listeners - Métodos: get, set, getConfig, getConfigMut, markDirty, isDirty 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 18 +- docs/CONFIGMANAGER_DESIGN.md | 348 ++++++++++++++ src/zcatconfig.zig | 904 ++++++++++++++++++++++++++++------- 3 files changed, 1094 insertions(+), 176 deletions(-) create mode 100644 docs/CONFIGMANAGER_DESIGN.md diff --git a/CLAUDE.md b/CLAUDE.md index 80c6f36..369be5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,18 +169,18 @@ PATH=/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2:$PATH zig ## PLAN DE TRABAJO -### Fase 1: Estructura base +### Fase 1: Estructura base - COMPLETADA - [x] Crear proyecto (build.zig, CLAUDE.md) -- [ ] Extraer types.zig de zsimifactu -- [ ] Adaptar engine.zig (parametrizar variables y Config) -- [ ] Adaptar persistence.zig +- [x] Extraer types.zig de zsimifactu +- [x] Adaptar engine.zig (parametrizar variables y Config) +- [x] Adaptar persistence.zig -### Fase 2: Generalizacion -- [ ] Engine generico con comptime -- [ ] Tests unitarios -- [ ] Documentacion API +### Fase 2: Generalizacion - COMPLETADA +- [x] Engine generico con comptime +- [x] Tests unitarios (9 tests) +- [x] Documentacion API -### Fase 3: Integracion +### Fase 3: Integracion - EN PROGRESO - [ ] Integrar en zsimifactu como dependencia - [ ] Verificar que zsimifactu funciona igual diff --git a/docs/CONFIGMANAGER_DESIGN.md b/docs/CONFIGMANAGER_DESIGN.md new file mode 100644 index 0000000..6c60b5c --- /dev/null +++ b/docs/CONFIGMANAGER_DESIGN.md @@ -0,0 +1,348 @@ +# ConfigManager - Diseño e Implementación + +**Fecha:** 2025-12-17 +**Versión:** v0.2.0 +**Estado:** En implementación + +--- + +## Objetivo + +Crear un gestor de configuración completo y autónomo que: +1. Gestione automáticamente el archivo de configuración +2. Mantenga las variables en memoria +3. Notifique cambios via observers (para sincronizar con BD u otros sistemas) +4. Minimice el trabajo requerido en proyectos futuros + +--- + +## Filosofía + +> "Invertir trabajo ahora para ahorrar problemas y trabajo en el futuro" + +La librería debe hacer todo el trabajo pesado. El programa solo debe: +1. Definir las variables (array de ConfigVariable) +2. Definir el struct Config +3. Crear el ConfigManager +4. Opcionalmente registrar observers + +--- + +## Arquitectura + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ZCATCONFIG │ +│ (Librería independiente, reutilizable) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ConfigManager(comptime variables, comptime ConfigType) │ +│ ├── allocator: Allocator │ +│ ├── config: ConfigType ← Datos en memoria │ +│ ├── file_path: []const u8 ← Ruta archivo │ +│ ├── app_name: []const u8 ← Nombre app (para headers) │ +│ ├── dirty: bool ← Cambios pendientes │ +│ ├── observers: []Observer ← Lista de callbacks │ +│ │ │ +│ ├── init(allocator, path, app_name) │ +│ │ └── Llama loadOrCreate() automáticamente │ +│ │ │ +│ ├── deinit() │ +│ │ └── Auto-save si dirty, libera recursos │ +│ │ │ +│ ├── get(key) → ConfigResult │ +│ ├── set(key, value) → !void │ +│ │ └── Marca dirty=true, notifica observers │ +│ │ │ +│ ├── getConfig() → *ConfigType │ +│ │ └── Acceso directo al struct (para campos tipados) │ +│ │ │ +│ ├── save() → !void │ +│ ├── load() → !void │ +│ ├── loadOrCreate() → !void │ +│ │ └── Si archivo no existe, guarda defaults │ +│ │ │ +│ ├── addObserver(callback) → void │ +│ ├── removeObserver(callback) → void │ +│ └── notifyObservers(change) → void (interno) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Tipos + +### ConfigChange + +Información sobre un cambio de configuración: + +```zig +pub const ConfigChange = struct { + key: []const u8, // Clave que cambió ("@auto_guardar") + variable: *const ConfigVariable, // Definición de la variable + old_value: ConfigResult, // Valor anterior + new_value: ConfigResult, // Valor nuevo +}; +``` + +### Observer + +Callback que recibe notificaciones de cambios: + +```zig +pub const Observer = *const fn (change: ConfigChange, config: *const ConfigType) void; +``` + +--- + +## Flujo de Uso + +### Uso Básico (sin BD) + +```zig +const zcatconfig = @import("zcatconfig"); + +// Definir variables (en variables.zig) +const config_variables = [_]zcatconfig.ConfigVariable{ + .{ .name = "timeout", .config_key = "@timeout", ... }, + // ... +}; + +// Definir struct (en structures.zig) +const Config = struct { + timeout: u32 = 30, + // ... +}; + +// En main o donde sea necesario: +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + // Crear manager - carga o crea archivo automáticamente + var config_manager = try zcatconfig.ConfigManager( + &config_variables, + Config + ).init(allocator, "mi_app_config.txt", "MiApp"); + defer config_manager.deinit(); // Auto-save si hay cambios + + // Usar configuración + const timeout = config_manager.getConfig().timeout; + + // Cambiar valor (notifica observers, marca dirty) + try config_manager.set("@timeout", "60"); +} +``` + +### Uso con Sincronización a BD + +```zig +// En DataManager: +pub const DataManager = struct { + config_manager: *zcatconfig.ConfigManager(&config_variables, Config), + db: *Database, + + pub fn init(allocator: Allocator, db: *Database) !*Self { + var self = try allocator.create(Self); + + // Crear config manager + self.config_manager = try zcatconfig.ConfigManager( + &config_variables, + Config + ).init(allocator, "config.txt", "zsimifactu"); + + // Registrar observer para sincronizar con BD + self.config_manager.addObserver(syncToDatabase); + + return self; + } + + fn syncToDatabase(change: ConfigChange, config: *const Config) void { + // INSERT OR REPLACE INTO config (key, value) VALUES (?, ?) + // Usar change.key y change.new_value + } +}; +``` + +--- + +## Comportamiento Automático + +### En init() + +1. Inicializa Config con valores por defecto +2. Llama `loadOrCreate()`: + - Si archivo existe → carga valores + - Si no existe → guarda archivo con defaults +3. `dirty = false` + +### En set() + +1. Obtiene valor actual (para ConfigChange) +2. Aplica nuevo valor al struct +3. `dirty = true` +4. Notifica a todos los observers + +### En deinit() + +1. Si `dirty == true` → llama `save()` +2. Libera recursos del Config (strings alocados) +3. Libera lista de observers + +--- + +## Integración con zsimifactu + +### Antes (actual) + +``` +DataManager +├── config: Config ← Struct directo +├── config_file_path: []const u8 +├── config_dirty: bool +├── loadConfig() / saveConfig() ← Métodos manuales +└── getConfigValue() / setConfigValue() +``` + +### Después (con ConfigManager) + +``` +DataManager +├── config_manager: *ConfigManager ← Gestor completo +└── (todo lo demás delegado al manager) + +// Acceso a config: +dm.config_manager.getConfig().mi_campo + +// Cambiar valor: +try dm.config_manager.set("@mi_campo", "valor"); +``` + +--- + +## Sincronización con BD + +### Estrategia + +1. ConfigManager notifica cambios via observer +2. DataManager registra un observer que escribe a BD +3. Al iniciar, se puede cargar desde BD y aplicar al ConfigManager + +### Tabla BD (ya existe en zsimifactu) + +```sql +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### Flujo de Sincronización + +``` +Inicio: +1. ConfigManager.init() → carga archivo (o crea con defaults) +2. DataManager carga valores de BD +3. Aplica valores BD al ConfigManager (si BD tiene prioridad) + +Durante ejecución: +1. Usuario cambia valor +2. ConfigManager.set() → actualiza memoria + notifica +3. Observer → escribe a BD +4. ConfigManager marca dirty + +Al cerrar: +1. ConfigManager.deinit() → guarda archivo (si dirty) +``` + +--- + +## Prioridad de Valores + +Orden de prioridad (mayor a menor): +1. **Cambios en runtime** - Siempre prevalecen +2. **Base de datos** - Persistencia principal +3. **Archivo config** - Backup/portabilidad +4. **Defaults en código** - Fallback + +--- + +## Archivos Afectados + +### zcatconfig (crear/modificar) + +- `src/zcatconfig.zig` - Añadir ConfigManager +- `docs/CONFIGMANAGER_DESIGN.md` - Este documento + +### zsimifactu (modificar) + +- `src/data_manager/data_manager.zig` - Usar ConfigManager +- `src/config/config.zig` - Simplificar (delegar a zcatconfig) + +--- + +## Plan de Implementación + +1. **Implementar ConfigManager en zcatconfig** + - Struct con todos los campos + - init/deinit con loadOrCreate automático + - get/set con notificación + - Sistema de observers + +2. **Actualizar zsimifactu** + - DataManager usa ConfigManager + - Registrar observer para BD + - Eliminar código duplicado + +3. **Probar** + - Borrar archivo config → debe regenerarse + - Cambiar valor → debe notificar y guardar + - Cerrar app → debe auto-guardar + +--- + +## Notas de Implementación Zig + +### Observers como ArrayList + +```zig +observers: std.ArrayList(Observer), + +pub fn addObserver(self: *Self, observer: Observer) void { + self.observers.append(self.allocator, observer) catch {}; +} + +fn notifyObservers(self: *Self, change: ConfigChange) void { + for (self.observers.items) |observer| { + observer(change, &self.config); + } +} +``` + +### ConfigManager como tipo genérico + +```zig +pub fn ConfigManager( + comptime variables: []const ConfigVariable, + comptime ConfigType: type, +) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + config: ConfigType, + file_path: []const u8, + app_name: []const u8, + dirty: bool, + observers: std.ArrayList(Observer), + + pub const Observer = *const fn (ConfigChange, *const ConfigType) void; + + pub fn init(...) !Self { ... } + pub fn deinit(self: *Self) void { ... } + // ... + }; +} +``` diff --git a/src/zcatconfig.zig b/src/zcatconfig.zig index da5e90f..bab131f 100644 --- a/src/zcatconfig.zig +++ b/src/zcatconfig.zig @@ -1,10 +1,11 @@ //! zcatconfig - Sistema de Configuracion Declarativo //! //! Libreria para gestion de configuracion con: -//! - Definicion declarativa de variables -//! - Generacion automatica de struct via comptime -//! - Persistencia a archivo de texto legible -//! - Validacion de valores +//! - Definicion declarativa de variables con metadatos +//! - Generacion automatica de acceso via comptime (inline for + @field) +//! - Validacion de valores (rangos, enums, tipos) +//! - Persistencia a archivo de texto legible con comentarios +//! - Soporte para variables read-only y no-exportables //! //! ## Uso basico //! @@ -13,54 +14,184 @@ //! const variables = @import("config/variables.zig"); //! const Config = @import("config/structures.zig").Config; //! -//! const Engine = zcatconfig.Engine(variables.config_variables, Config); +//! const MyEngine = zcatconfig.Engine(variables.config_variables, Config); //! -//! var config = Config{}; -//! Engine.load(&config, "config.txt") catch {}; +//! var config = Config.init(allocator); +//! defer config.deinit(); +//! zcatconfig.load(variables.config_variables, Config, &config, "config.txt") catch {}; //! config.font_size = 16; -//! try Engine.save(&config, "config.txt"); +//! try zcatconfig.save(variables.config_variables, Config, &config, "config.txt"); //! ``` const std = @import("std"); // ============================================================================= -// TIPOS +// TIPOS BASE // ============================================================================= -/// Tipo de variable de configuracion +/// Tipos de valores soportados en la configuracion pub const ConfigVarType = enum { - boolean, - integer, - float, + /// Valores texto libres string, + /// Si/No -> true/false + boolean, + /// Numeros enteros + integer, + /// Numeros decimales + float, + /// "NombreColor" o "RGB(r,g,b)" color, - // Extensible para enums custom + /// CSV -> []const u8 (separado por comas) + string_array, + + /// Nombre legible del tipo para comentarios + pub fn displayName(self: ConfigVarType) []const u8 { + return switch (self) { + .string => "texto", + .boolean => "Si/No", + .integer => "numero", + .float => "decimal", + .color => "color", + .string_array => "lista", + }; + } }; -/// Categoria de variable (para UI de configuracion) -pub const ConfigCategory = enum { - general, - apariencia, - comportamiento, - avanzado, +/// Definicion declarativa de una variable de configuracion. +/// Una sola definicion genera: parsing, serializacion, defaults, +/// validacion y comentarios en archivo. +pub const ConfigVariable = struct { + /// Nombre del campo en la estructura Zig: "auto_guardar_cada" + name: []const u8, + + /// Clave en archivo de configuracion: "@auto_guardar_cada" + config_key: []const u8, + + /// Tipo de valor + var_type: ConfigVarType, + + /// Valor por defecto como string (se parsea segun tipo) + default: []const u8, + + /// Validacion automatica: + /// - Para integer: "1-1440" (rango min-max) + /// - Para string: "Asc,Desc" (enum de valores validos) + /// - null = sin validacion especial + auto_validate: ?[]const u8 = null, + + /// Descripcion para comentario inline en archivo + description: []const u8, + + /// Categoria para agrupar en archivo y UI (indice en array de categorias) + category: usize = 0, + + /// Texto multi-linea para header de seccion (solo primera variable de categoria) + category_header: ?[]const u8 = null, + + /// Si es true, no se puede editar desde UI (ej: paleta base) + is_read_only: bool = false, + + /// Si es true, se exporta al archivo de configuracion + export_to_file: bool = true, + + /// Valida un valor segun las reglas de esta variable + pub fn validate(self: ConfigVariable, value: []const u8) bool { + if (self.auto_validate) |validation| { + return switch (self.var_type) { + .integer => self.validateIntRange(value, validation), + .float => self.validateFloatRange(value, validation), + .string => self.validateEnum(value, validation), + .boolean => self.validateBoolean(value), + .color => self.validateColor(value), + .string_array => true, // Arrays siempre validos + }; + } + // Sin validacion especifica, validar tipo basico + return switch (self.var_type) { + .boolean => self.validateBoolean(value), + .integer => std.fmt.parseInt(i32, value, 10) != error.InvalidCharacter, + .float => std.fmt.parseFloat(f32, value) != error.InvalidCharacter, + else => true, + }; + } + + fn validateIntRange(self: ConfigVariable, value: []const u8, range: []const u8) bool { + _ = self; + const parsed = std.fmt.parseInt(i32, value, 10) catch return false; + + // Parse "min-max" + var it = std.mem.splitScalar(u8, range, '-'); + const min_str = it.next() orelse return false; + const max_str = it.next() orelse return false; + + const min = std.fmt.parseInt(i32, min_str, 10) catch return false; + const max = std.fmt.parseInt(i32, max_str, 10) catch return false; + + return parsed >= min and parsed <= max; + } + + fn validateFloatRange(self: ConfigVariable, value: []const u8, range: []const u8) bool { + _ = self; + const parsed = std.fmt.parseFloat(f32, value) catch return false; + + // Parse "min-max" + var it = std.mem.splitScalar(u8, range, '-'); + const min_str = it.next() orelse return false; + const max_str = it.next() orelse return false; + + const min = std.fmt.parseFloat(f32, min_str) catch return false; + const max = std.fmt.parseFloat(f32, max_str) catch return false; + + return parsed >= min and parsed <= max; + } + + fn validateEnum(self: ConfigVariable, value: []const u8, valid_values: []const u8) bool { + _ = self; + var it = std.mem.splitScalar(u8, valid_values, ','); + while (it.next()) |valid| { + if (std.mem.eql(u8, value, valid)) return true; + } + return false; + } + + fn validateBoolean(_: ConfigVariable, value: []const u8) bool { + return std.mem.eql(u8, value, "Si") or + std.mem.eql(u8, value, "No") or + std.mem.eql(u8, value, "si") or + std.mem.eql(u8, value, "no") or + std.mem.eql(u8, value, "true") or + std.mem.eql(u8, value, "false") or + std.mem.eql(u8, value, "1") or + std.mem.eql(u8, value, "0"); + } + + fn validateColor(_: ConfigVariable, value: []const u8) bool { + // Acepta nombres de color o RGB(r,g,b) + if (std.mem.startsWith(u8, value, "RGB(") and std.mem.endsWith(u8, value, ")")) { + return Color.parse(value) != null; + } + // Cualquier nombre de color es valido (se valida contra paleta en runtime) + return value.len > 0; + } }; -/// Color RGB +/// Representa un color RGBA pub const Color = struct { r: u8 = 0, g: u8 = 0, b: u8 = 0, + a: u8 = 255, pub fn rgb(r: u8, g: u8, b: u8) Color { - return .{ .r = r, .g = g, .b = b }; + return .{ .r = r, .g = g, .b = b, .a = 255 }; } - pub fn format(self: Color, allocator: std.mem.Allocator) ![]u8 { - return std.fmt.allocPrint(allocator, "RGB({},{},{})", .{ self.r, self.g, self.b }); + pub fn rgba(r: u8, g: u8, b: u8, a: u8) Color { + return .{ .r = r, .g = g, .b = b, .a = a }; } + /// Parsea "RGB(r,g,b)" -> Color pub fn parse(str: []const u8) ?Color { - // Formato: RGB(r,g,b) if (!std.mem.startsWith(u8, str, "RGB(")) return null; if (!std.mem.endsWith(u8, str, ")")) return null; @@ -77,26 +208,39 @@ pub const Color = struct { return Color.rgb(r, g, b); } + + /// Alias para parse (compatibilidad) + pub const fromRgbString = parse; + + /// Formatea a "RGB(r,g,b)" + pub fn format(self: Color, buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, "RGB({d},{d},{d})", .{ self.r, self.g, self.b }) catch ""; + } + + /// Formatea a "RGB(r,g,b)" - version que aloca + pub fn formatAlloc(self: Color, allocator: std.mem.Allocator) ![]u8 { + return std.fmt.allocPrint(allocator, "RGB({d},{d},{d})", .{ self.r, self.g, self.b }); + } }; -/// Definicion de una variable de configuracion -pub const ConfigVariable = struct { - /// Nombre del campo en el struct Config - name: []const u8, - /// Clave en el archivo de configuracion (ej: "@auto_save") - config_key: []const u8, - /// Tipo de la variable - var_type: ConfigVarType, - /// Valor por defecto como string - default: []const u8, - /// Descripcion para documentacion/UI - description: []const u8, - /// Categoria para agrupar en UI - category: ConfigCategory = .general, - /// Valor minimo (para integer/float) - min: ?i32 = null, - /// Valor maximo (para integer/float) - max: ?i32 = null, +// ============================================================================= +// ERRORES +// ============================================================================= + +/// Error al acceder a configuracion +pub const ConfigError = error{ + /// Clave no encontrada + KeyNotFound, + /// Valor invalido para el tipo + InvalidValue, + /// Variable es read-only + ReadOnly, + /// Error de validacion + ValidationFailed, + /// Error de parseo + ParseError, + /// Error de memoria + OutOfMemory, }; /// Resultado de una operacion get @@ -106,8 +250,22 @@ pub const ConfigResult = union(enum) { float: f32, string: []const u8, color: Color, + string_array: []const u8, // CSV como string not_found, type_mismatch, + + /// Convierte a string para serializacion + pub fn toString(self: ConfigResult, buf: []u8) []const u8 { + return switch (self) { + .string, .string_array => |s| s, + .boolean => |b| if (b) "Si" else "No", + .integer => |i| std.fmt.bufPrint(buf, "{d}", .{i}) catch "", + .float => |f| std.fmt.bufPrint(buf, "{d:.2}", .{f}) catch "", + .color => |c| c.format(buf), + .not_found => "???", + .type_mismatch => "???", + }; + } }; // ============================================================================= @@ -119,86 +277,114 @@ pub fn Engine(comptime variables: []const ConfigVariable, comptime ConfigType: t return struct { const Self = @This(); - /// Obtiene el valor de una variable por nombre - pub fn get(config: *const ConfigType, name: []const u8) ConfigResult { - inline for (variables) |v| { - if (std.mem.eql(u8, v.name, name)) { - return getField(config, v); + /// Busca variable por config_key (ej: "@auto_save") + pub fn findByKey(key: []const u8) ?*const ConfigVariable { + for (variables) |*v| { + if (std.mem.eql(u8, v.config_key, key)) { + return v; } } - return .not_found; + return null; } - /// Obtiene el valor de un campo segun su definicion - fn getField(config: *const ConfigType, comptime v: ConfigVariable) ConfigResult { + /// Busca variable por nombre de campo (ej: "auto_save") + pub fn findByName(name: []const u8) ?*const ConfigVariable { + for (variables) |*v| { + if (std.mem.eql(u8, v.name, name)) { + return v; + } + } + return null; + } + + /// Obtiene un valor de configuracion por su clave (@variable) + pub fn get(config: *const ConfigType, key: []const u8) ConfigError!ConfigResult { + const variable = findByKey(key) orelse return ConfigError.KeyNotFound; + return getByVariable(config, variable); + } + + /// Obtiene valor usando la definicion de variable directamente + pub fn getByVariable(config: *const ConfigType, variable: *const ConfigVariable) ConfigResult { + inline for (variables) |v| { + if (std.mem.eql(u8, variable.name, v.name)) { + return getFieldByDef(config, v); + } + } + return .{ .string = variable.default }; + } + + /// Obtiene el valor de un campo segun su definicion (comptime) + fn getFieldByDef(config: *const ConfigType, comptime v: ConfigVariable) ConfigResult { const value = @field(config.*, v.name); return switch (v.var_type) { .boolean => .{ .boolean = value }, .integer => .{ .integer = value }, .float => .{ .float = value }, - .string => .{ .string = value }, - .color => .{ .color = value }, + .string, .string_array => .{ .string = value }, + .color => blk: { + const T = @TypeOf(value); + if (T == Color) { + break :blk .{ .color = value }; + } else { + break :blk .{ .string = value }; + } + }, }; } - /// Establece el valor de una variable por nombre (desde string) - pub fn setFromString(config: *ConfigType, name: []const u8, str_value: []const u8) bool { - inline for (variables) |v| { - if (std.mem.eql(u8, v.name, name)) { - return setFieldFromString(config, v, str_value); - } - } - return false; + /// Establece un valor de configuracion por su clave + pub fn set(config: *ConfigType, key: []const u8, value: []const u8) ConfigError!void { + const variable = findByKey(key) orelse return ConfigError.KeyNotFound; + return setByVariable(config, variable, value); } - /// Establece un campo desde string - fn setFieldFromString(config: *ConfigType, comptime v: ConfigVariable, str_value: []const u8) bool { + /// Establece valor usando la definicion de variable + pub fn setByVariable(config: *ConfigType, variable: *const ConfigVariable, value: []const u8) ConfigError!void { + // Verificar read-only + if (variable.is_read_only) return ConfigError.ReadOnly; + + // Validar valor + if (!variable.validate(value)) return ConfigError.ValidationFailed; + + // inline for desenrolla el bucle en comptime + inline for (variables) |v| { + if (std.mem.eql(u8, variable.name, v.name)) { + try setFieldByDef(config, v, value); + return; + } + } + return ConfigError.KeyNotFound; + } + + /// Establece el valor de un campo segun su definicion (comptime) + fn setFieldByDef(config: *ConfigType, comptime v: ConfigVariable, value: []const u8) ConfigError!void { switch (v.var_type) { .boolean => { - if (std.mem.eql(u8, str_value, "Si") or - std.mem.eql(u8, str_value, "si") or - std.mem.eql(u8, str_value, "true") or - std.mem.eql(u8, str_value, "1")) - { - @field(config.*, v.name) = true; - return true; - } else if (std.mem.eql(u8, str_value, "No") or - std.mem.eql(u8, str_value, "no") or - std.mem.eql(u8, str_value, "false") or - std.mem.eql(u8, str_value, "0")) - { - @field(config.*, v.name) = false; - return true; - } - return false; + const bool_val = parseBool(value) orelse return ConfigError.InvalidValue; + @field(config.*, v.name) = bool_val; }, .integer => { - const val = std.fmt.parseInt(i32, str_value, 10) catch return false; - // Validar rango si existe - if (v.min) |min| { - if (val < min) return false; - } - if (v.max) |max| { - if (val > max) return false; - } - @field(config.*, v.name) = val; - return true; + const int_val = std.fmt.parseInt(i32, value, 10) catch return ConfigError.InvalidValue; + @field(config.*, v.name) = int_val; }, .float => { - const val = std.fmt.parseFloat(f32, str_value) catch return false; - @field(config.*, v.name) = val; - return true; + const float_val = std.fmt.parseFloat(f32, value) catch return ConfigError.InvalidValue; + @field(config.*, v.name) = float_val; }, - .string => { - @field(config.*, v.name) = str_value; - return true; + .string, .string_array => { + // Para strings, necesitamos que Config tenga dupeString o similar + // Si el campo es []const u8 con default, simplemente asignamos + // (asumimos que el llamador maneja la memoria) + @field(config.*, v.name) = value; }, .color => { - if (Color.parse(str_value)) |c| { - @field(config.*, v.name) = c; - return true; + const FieldType = @TypeOf(@field(config.*, v.name)); + if (FieldType == Color) { + const color = Color.parse(value) orelse return ConfigError.InvalidValue; + @field(config.*, v.name) = color; + } else { + @field(config.*, v.name) = value; } - return false; }, } } @@ -210,11 +396,27 @@ pub fn Engine(comptime variables: []const ConfigVariable, comptime ConfigType: t }; } +/// Parsea "Si"/"No" o "true"/"false" a bool +fn parseBool(value: []const u8) ?bool { + if (std.mem.eql(u8, value, "Si") or std.mem.eql(u8, value, "si") or + std.mem.eql(u8, value, "true") or std.mem.eql(u8, value, "1")) + { + return true; + } + if (std.mem.eql(u8, value, "No") or std.mem.eql(u8, value, "no") or + std.mem.eql(u8, value, "false") or std.mem.eql(u8, value, "0")) + { + return false; + } + return null; +} + // ============================================================================= // PERSISTENCE // ============================================================================= -/// Carga configuracion desde archivo +/// Carga configuracion desde archivo. +/// Requiere que ConfigType tenga un campo `allocator`. pub fn load( comptime variables: []const ConfigVariable, comptime ConfigType: type, @@ -223,42 +425,54 @@ pub fn load( ) !void { const EngineType = Engine(variables, ConfigType); - const file = try std.fs.cwd().openFile(path, .{}); - defer file.close(); + // Leer archivo completo (propaga FileNotFound para que loadOrCreate pueda crear) + const content = try std.fs.cwd().readFileAlloc(config.allocator, path, 1024 * 1024); + defer config.allocator.free(content); - var buf_reader = std.io.bufferedReader(file.reader()); - var reader = buf_reader.reader(); - - var line_buf: [1024]u8 = undefined; - while (reader.readUntilDelimiterOrEof(&line_buf, '\n')) |line_opt| { - const line = line_opt orelse break; - - // Ignorar comentarios y lineas vacias + // Parsear linea por linea + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r"); if (trimmed.len == 0) continue; if (trimmed[0] == '#') continue; + if (trimmed[0] != '@') continue; // Buscar separador ':' - if (std.mem.indexOf(u8, trimmed, ":")) |sep_idx| { - const key = std.mem.trim(u8, trimmed[0..sep_idx], " \t"); - var value = std.mem.trim(u8, trimmed[sep_idx + 1 ..], " \t"); + const colon_pos = std.mem.indexOf(u8, trimmed, ":") orelse continue; + const key = std.mem.trim(u8, trimmed[0..colon_pos], " \t"); + const after_colon = trimmed[colon_pos + 1 ..]; - // Quitar comentario inline - if (std.mem.indexOf(u8, value, "#")) |hash_idx| { - value = std.mem.trim(u8, value[0..hash_idx], " \t"); + // Quitar comentario inline (patron " #") + const comment_pos = findCommentStart(after_colon); + const value_with_spaces = if (comment_pos) |pos| + after_colon[0..pos] + else + after_colon; + const value = std.mem.trim(u8, value_with_spaces, " \t"); + + // Aplicar el valor + EngineType.set(config, key, value) catch { + // Variable no encontrada o valor invalido - ignorar + continue; + }; + } +} + +/// Busca el inicio de un comentario real (espacio + #, no # dentro del valor) +fn findCommentStart(text: []const u8) ?usize { + var i: usize = 0; + while (i < text.len) : (i += 1) { + if (text[i] == '#') { + // Verificar si hay al menos 2 espacios antes + if (i >= 2 and text[i - 1] == ' ' and text[i - 2] == ' ') { + return i - 2; } - - // Buscar variable por config_key - inline for (variables) |v| { - if (std.mem.eql(u8, key, v.config_key)) { - _ = EngineType.setFromString(config, v.name, value); - break; - } + if (i >= 1 and text[i - 1] == '\t') { + return i - 1; } } - } else |err| { - return err; } + return null; } /// Guarda configuracion a archivo @@ -266,54 +480,316 @@ pub fn save( comptime variables: []const ConfigVariable, comptime ConfigType: type, config: *const ConfigType, - allocator: std.mem.Allocator, path: []const u8, + comptime app_name: []const u8, ) !void { const EngineType = Engine(variables, ConfigType); const file = try std.fs.cwd().createFile(path, .{}); defer file.close(); - var writer = file.writer(); + // Header del archivo + try file.writeAll("# ============================================================================\n"); + var header_buf: [128]u8 = undefined; + const header_line = std.fmt.bufPrint(&header_buf, "# {s} - Archivo de Configuracion\n", .{app_name}) catch ""; + try file.writeAll(header_line); + try file.writeAll("# ============================================================================\n"); + try file.writeAll("# \n"); + try file.writeAll("# Este archivo se genera automaticamente. Puedes editarlo manualmente.\n"); + try file.writeAll("# Formato: @variable: valor # descripcion\n"); + try file.writeAll("# \n"); + try file.writeAll("\n"); - // Header - try writer.writeAll("# Archivo de configuracion (autogenerado)\n"); - try writer.writeAll("# Formato: @variable: valor # descripcion\n\n"); - - var current_category: ?ConfigCategory = null; + var current_category: ?usize = null; + var value_buf: [256]u8 = undefined; + var line_buf: [512]u8 = undefined; inline for (variables) |v| { - // Separador de categoria + // No exportar variables marcadas como no-export + if (!v.export_to_file) continue; + + // Header de categoria si cambio if (current_category == null or current_category.? != v.category) { + if (current_category != null) { + try file.writeAll("\n"); + } + + // Si la variable tiene header personalizado, usarlo + if (v.category_header) |header| { + try file.writeAll(header); + try file.writeAll("\n"); + } + current_category = v.category; - try writer.print("\n# === {} ===\n", .{@tagName(v.category)}); } // Obtener valor actual - const result = EngineType.get(config, v.name); - const value_str = switch (result) { - .boolean => |b| if (b) "Si" else "No", - .integer => |i| blk: { - const buf = try allocator.alloc(u8, 16); - const len = std.fmt.formatInt(i, 10, .lower, .{}, buf) catch 0; - break :blk buf[0..len]; - }, - .float => |f| blk: { - const buf = try allocator.alloc(u8, 32); - const slice = std.fmt.bufPrint(buf, "{d:.2}", .{f}) catch ""; - break :blk slice; - }, - .string => |s| s, - .color => |c| blk: { - break :blk try c.format(allocator); - }, - else => "???", + const result = EngineType.getByVariable(config, &v); + const value = result.toString(&value_buf); + + // Construir linea con padding para alinear comentarios + const key_len = v.config_key.len; + const value_len = value.len; + const total_len = key_len + 2 + value_len; + + if (total_len < 40 and v.description.len > 0) { + const padding = 40 - total_len; + const line = std.fmt.bufPrint(&line_buf, "{s}: {s}{s}# {s}\n", .{ + v.config_key, + value, + spaces(padding), + v.description, + }) catch unreachable; // Buffer 1024 es suficiente + try file.writeAll(line); + } else { + const line = std.fmt.bufPrint(&line_buf, "{s}: {s}\n", .{ + v.config_key, + value, + }) catch unreachable; // Buffer 1024 es suficiente + try file.writeAll(line); + } + } + + try file.writeAll("\n"); + try file.writeAll("# ============================================================================\n"); + try file.writeAll("# Fin del archivo de configuracion\n"); + try file.writeAll("# ============================================================================\n"); +} + +/// Genera string de N espacios (maximo 64) +fn spaces(n: usize) []const u8 { + const space_buf = " "; // 64 espacios + return space_buf[0..@min(n, 64)]; +} + +/// Crea backup del archivo de configuracion +pub fn createBackup(path: []const u8) !void { + const now = std.time.timestamp(); + var backup_path_buf: [512]u8 = undefined; + const backup_path = std.fmt.bufPrint(&backup_path_buf, "{s}.{d}.bak", .{ path, now }) catch return; + + std.fs.cwd().copyFile(path, std.fs.cwd(), backup_path, .{}) catch |err| { + if (err == error.FileNotFound) return; + return err; + }; +} + +// ============================================================================= +// CONFIG MANAGER +// ============================================================================= + +/// Informacion sobre un cambio de configuracion +pub const ConfigChange = struct { + key: []const u8, + variable: *const ConfigVariable, + old_value: ConfigResult, + new_value: ConfigResult, +}; + +/// Gestor de configuracion completo y autonomo +/// +/// Maneja automaticamente: +/// - Carga/creacion del archivo de configuracion +/// - Almacenamiento en memoria +/// - Notificacion de cambios via observers +/// - Auto-guardado al cerrar +/// +/// Ejemplo de uso: +/// ```zig +/// var manager = try ConfigManager(&my_vars, MyConfig).init(allocator, "config.txt", "MiApp"); +/// defer manager.deinit(); // Auto-save si hay cambios +/// +/// // Acceso directo +/// const value = manager.getConfig().mi_campo; +/// +/// // Cambio con notificacion +/// try manager.set("@mi_campo", "nuevo_valor"); +/// ``` +pub fn ConfigManager( + comptime variables: []const ConfigVariable, + comptime ConfigType: type, + comptime app_name: []const u8, +) type { + return struct { + const Self = @This(); + const EngineType = Engine(variables, ConfigType); + + /// Callback para notificacion de cambios (con contexto opcional) + pub const ObserverFn = *const fn (change: ConfigChange, config: *const ConfigType, ctx: ?*anyopaque) void; + + /// Entrada de observer con contexto + pub const ObserverEntry = struct { + callback: ObserverFn, + context: ?*anyopaque, }; - try writer.print("{s}: {s} # {s}\n", .{ v.config_key, value_str, v.description }); - } + allocator: std.mem.Allocator, + config: ConfigType, + file_path: []const u8, + dirty: bool, + observers: std.ArrayList(ObserverEntry), + + /// Inicializa el ConfigManager + /// - Crea Config con valores por defecto + /// - Llama loadOrCreate() automaticamente + pub fn init( + allocator: std.mem.Allocator, + file_path: []const u8, + ) !Self { + var self = Self{ + .allocator = allocator, + .config = ConfigType.init(allocator), + .file_path = file_path, + .dirty = false, + .observers = .{}, + }; + + // Cargar o crear archivo automaticamente + try self.loadOrCreate(); + + return self; + } + + /// Libera recursos y guarda si hay cambios pendientes + pub fn deinit(self: *Self) void { + // Auto-save si hay cambios + if (self.dirty) { + self.save() catch {}; + } + + // Liberar config + self.config.deinit(); + + // Liberar observers + self.observers.deinit(self.allocator); + } + + /// Acceso directo al struct de configuracion (solo lectura) + pub fn getConfig(self: *const Self) *const ConfigType { + return &self.config; + } + + /// Acceso mutable al struct (para modificaciones directas) + /// NOTA: Las modificaciones directas NO notifican observers ni marcan dirty + pub fn getConfigMut(self: *Self) *ConfigType { + return &self.config; + } + + /// Obtiene un valor por clave + pub fn get(self: *const Self, key: []const u8) ConfigError!ConfigResult { + return EngineType.get(&self.config, key); + } + + /// Establece un valor por clave + /// - Notifica a todos los observers + /// - Marca dirty=true + pub fn set(self: *Self, key: []const u8, value: []const u8) ConfigError!void { + // Obtener valor anterior para el change + const old_value = EngineType.get(&self.config, key) catch |err| { + return err; + }; + + // Encontrar la variable para el change + const variable = findVariableByKey(key) orelse return ConfigError.KeyNotFound; + + // Aplicar cambio + try EngineType.set(&self.config, key, value); + + // Obtener nuevo valor + const new_value = EngineType.get(&self.config, key) catch unreachable; + + // Marcar dirty + self.dirty = true; + + // Notificar observers + const change = ConfigChange{ + .key = key, + .variable = variable, + .old_value = old_value, + .new_value = new_value, + }; + self.notifyObservers(change); + } + + /// Busca variable por config_key + fn findVariableByKey(key: []const u8) ?*const ConfigVariable { + inline for (variables) |*v| { + if (std.mem.eql(u8, v.config_key, key)) { + return v; + } + } + return null; + } + + /// Carga configuracion desde archivo + pub fn load(self: *Self) !void { + try loadFn(variables, ConfigType, &self.config, self.file_path); + self.dirty = false; + } + + /// Guarda configuracion a archivo + pub fn save(self: *Self) !void { + try saveFn(variables, ConfigType, &self.config, self.file_path, app_name); + self.dirty = false; + } + + /// Carga configuracion o crea archivo con defaults si no existe + pub fn loadOrCreate(self: *Self) !void { + self.load() catch |err| { + if (err == error.FileNotFound) { + // Archivo no existe - guardar defaults + try self.save(); + return; + } + return err; + }; + } + + /// Registra un observer para recibir notificaciones de cambios + /// @param callback: funcion a llamar cuando hay cambios + /// @param context: puntero opcional que se pasa al callback (ej: puntero a DB) + pub fn addObserver(self: *Self, callback: ObserverFn, context: ?*anyopaque) void { + self.observers.append(self.allocator, .{ + .callback = callback, + .context = context, + }) catch {}; + } + + /// Elimina un observer por su callback + pub fn removeObserver(self: *Self, callback: ObserverFn) void { + for (self.observers.items, 0..) |entry, i| { + if (entry.callback == callback) { + _ = self.observers.orderedRemove(i); + return; + } + } + } + + /// Notifica a todos los observers de un cambio + fn notifyObservers(self: *Self, change: ConfigChange) void { + for (self.observers.items) |entry| { + entry.callback(change, &self.config, entry.context); + } + } + + /// Marca la configuracion como modificada (para cambios directos al struct) + pub fn markDirty(self: *Self) void { + self.dirty = true; + } + + /// Indica si hay cambios sin guardar + pub fn isDirty(self: *const Self) bool { + return self.dirty; + } + }; } +/// Funcion load renombrada para evitar conflicto con metodo +const loadFn = load; + +/// Funcion save renombrada para evitar conflicto con metodo +const saveFn = save; + // ============================================================================= // TESTS // ============================================================================= @@ -323,14 +799,65 @@ test "Color parse and format" { try std.testing.expectEqual(@as(u8, 255), c.r); try std.testing.expectEqual(@as(u8, 128), c.g); try std.testing.expectEqual(@as(u8, 64), c.b); + try std.testing.expectEqual(@as(u8, 255), c.a); } test "Color parse invalid" { try std.testing.expect(Color.parse("invalid") == null); - try std.testing.expect(Color.parse("RGB(256,0,0)") == null); // 256 > u8 + try std.testing.expect(Color.parse("RGB(256,0,0)") == null); } -test "Engine basic" { +test "ConfigVariable validation - integer range" { + const int_var = ConfigVariable{ + .name = "test_int", + .config_key = "@test_int", + .var_type = .integer, + .default = "15", + .auto_validate = "1-100", + .description = "Test integer", + }; + + try std.testing.expect(int_var.validate("50")); + try std.testing.expect(int_var.validate("1")); + try std.testing.expect(int_var.validate("100")); + try std.testing.expect(!int_var.validate("0")); + try std.testing.expect(!int_var.validate("101")); + try std.testing.expect(!int_var.validate("abc")); +} + +test "ConfigVariable validation - enum" { + const enum_var = ConfigVariable{ + .name = "test_enum", + .config_key = "@test_enum", + .var_type = .string, + .default = "Asc", + .auto_validate = "Asc,Desc,None", + .description = "Test enum", + }; + + try std.testing.expect(enum_var.validate("Asc")); + try std.testing.expect(enum_var.validate("Desc")); + try std.testing.expect(enum_var.validate("None")); + try std.testing.expect(!enum_var.validate("Invalid")); +} + +test "ConfigVariable validation - boolean" { + const bool_var = ConfigVariable{ + .name = "test_bool", + .config_key = "@test_bool", + .var_type = .boolean, + .default = "Si", + .description = "Test boolean", + }; + + try std.testing.expect(bool_var.validate("Si")); + try std.testing.expect(bool_var.validate("No")); + try std.testing.expect(bool_var.validate("true")); + try std.testing.expect(bool_var.validate("false")); + try std.testing.expect(!bool_var.validate("maybe")); +} + +test "Engine basic operations" { const test_vars = [_]ConfigVariable{ .{ .name = "enabled", @@ -345,14 +872,21 @@ test "Engine basic" { .var_type = .integer, .default = "10", .description = "Contador", - .min = 0, - .max = 100, + .auto_validate = "0-100", + }, + .{ + .name = "ratio", + .config_key = "@ratio", + .var_type = .float, + .default = "1.5", + .description = "Ratio", }, }; const TestConfig = struct { enabled: bool = true, count: i32 = 10, + ratio: f32 = 1.5, }; const TestEngine = Engine(&test_vars, TestConfig); @@ -360,22 +894,58 @@ test "Engine basic" { var config = TestConfig{}; // Test get - const enabled_result = TestEngine.get(&config, "enabled"); + const enabled_result = try TestEngine.get(&config, "@enabled"); try std.testing.expectEqual(true, enabled_result.boolean); - // Test setFromString - try std.testing.expect(TestEngine.setFromString(&config, "enabled", "No")); + const count_result = try TestEngine.get(&config, "@count"); + try std.testing.expectEqual(@as(i32, 10), count_result.integer); + + // Test set + try TestEngine.set(&config, "@enabled", "No"); try std.testing.expectEqual(false, config.enabled); - try std.testing.expect(TestEngine.setFromString(&config, "count", "42")); + try TestEngine.set(&config, "@count", "42"); try std.testing.expectEqual(@as(i32, 42), config.count); - // Test range validation - try std.testing.expect(!TestEngine.setFromString(&config, "count", "200")); // > max + // Test validation + const result = TestEngine.set(&config, "@count", "200"); + try std.testing.expectError(ConfigError.ValidationFailed, result); try std.testing.expectEqual(@as(i32, 42), config.count); // unchanged } -test "version" { - // Placeholder test - try std.testing.expect(true); +test "Engine read-only protection" { + const test_vars = [_]ConfigVariable{ + .{ + .name = "readonly_val", + .config_key = "@readonly", + .var_type = .integer, + .default = "100", + .description = "Read only value", + .is_read_only = true, + }, + }; + + const TestConfig = struct { + readonly_val: i32 = 100, + }; + + const TestEngine = Engine(&test_vars, TestConfig); + + var config = TestConfig{}; + const result = TestEngine.set(&config, "@readonly", "200"); + try std.testing.expectError(ConfigError.ReadOnly, result); + try std.testing.expectEqual(@as(i32, 100), config.readonly_val); +} + +test "ConfigResult toString" { + var buf: [64]u8 = undefined; + + const bool_result = ConfigResult{ .boolean = true }; + try std.testing.expectEqualStrings("Si", bool_result.toString(&buf)); + + const int_result = ConfigResult{ .integer = 42 }; + try std.testing.expectEqualStrings("42", int_result.toString(&buf)); + + const color_result = ConfigResult{ .color = Color.rgb(255, 0, 128) }; + try std.testing.expectEqualStrings("RGB(255,0,128)", color_result.toString(&buf)); }