//! zcatconfig - Sistema de Configuracion Declarativo //! //! Libreria para gestion de configuracion con: //! - 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 //! //! ```zig //! const zcatconfig = @import("zcatconfig"); //! const variables = @import("config/variables.zig"); //! const Config = @import("config/structures.zig").Config; //! //! const MyEngine = zcatconfig.Engine(variables.config_variables, Config); //! //! var config = Config.init(allocator); //! defer config.deinit(); //! zcatconfig.load(variables.config_variables, Config, &config, "config.txt") catch {}; //! config.font_size = 16; //! try zcatconfig.save(variables.config_variables, Config, &config, "config.txt"); //! ``` const std = @import("std"); // ============================================================================= // TIPOS BASE // ============================================================================= /// Tipos de valores soportados en la configuracion pub const ConfigVarType = enum { /// Valores texto libres string, /// Si/No -> true/false boolean, /// Numeros enteros integer, /// Numeros decimales float, /// "NombreColor" o "RGB(r,g,b)" color, /// 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", }; } }; /// 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" (soporta negativos, ej: "-100-100", "-50-50", "0-100") // Buscar el '-' separador: es el primero que NO está al inicio const separator_pos = blk: { const start: usize = if (range.len > 0 and range[0] == '-') 1 else 0; for (range[start..], start..) |c, i| { if (c == '-') break :blk i; } return false; // No encontró separador }; const min_str = range[0..separator_pos]; const max_str = range[separator_pos + 1 ..]; 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" (soporta negativos) const separator_pos = blk: { const start: usize = if (range.len > 0 and range[0] == '-') 1 else 0; for (range[start..], start..) |c, i| { if (c == '-') break :blk i; } return false; }; const min_str = range[0..separator_pos]; const max_str = range[separator_pos + 1 ..]; 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; } }; /// 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, .a = 255 }; } 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 { if (!std.mem.startsWith(u8, str, "RGB(")) return null; if (!std.mem.endsWith(u8, str, ")")) return null; const inner = str[4 .. str.len - 1]; var it = std.mem.splitScalar(u8, inner, ','); const r_str = it.next() orelse return null; const g_str = it.next() orelse return null; const b_str = it.next() orelse return null; const r = std.fmt.parseInt(u8, std.mem.trim(u8, r_str, " "), 10) catch return null; const g = std.fmt.parseInt(u8, std.mem.trim(u8, g_str, " "), 10) catch return null; const b = std.fmt.parseInt(u8, std.mem.trim(u8, b_str, " "), 10) catch return null; 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 }); } }; // ============================================================================= // 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 pub const ConfigResult = union(enum) { boolean: bool, integer: i32, 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 => "???", }; } }; // ============================================================================= // ENGINE // ============================================================================= /// Crea un Engine de configuracion para un conjunto de variables y struct Config pub fn Engine(comptime variables: []const ConfigVariable, comptime ConfigType: type) type { return struct { const Self = @This(); /// 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 null; } /// 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_array => .{ .string = value }, .color => blk: { const T = @TypeOf(value); if (T == Color) { break :blk .{ .color = value }; } else { break :blk .{ .string = value }; } }, }; } /// 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 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 => { const bool_val = parseBool(value) orelse return ConfigError.InvalidValue; @field(config.*, v.name) = bool_val; }, .integer => { const int_val = std.fmt.parseInt(i32, value, 10) catch return ConfigError.InvalidValue; @field(config.*, v.name) = int_val; }, .float => { const float_val = std.fmt.parseFloat(f32, value) catch return ConfigError.InvalidValue; @field(config.*, v.name) = float_val; }, .string, .string_array => { // IMPORTANTE: Duplicar el string porque 'value' apunta a 'content' // que se libera con defer al final de load() if (@hasDecl(ConfigType, "dupeString")) { // Liberar string anterior si existe (evitar leak en hot-reload) if (@hasDecl(ConfigType, "freeString")) { const old_val = @field(config.*, v.name); if (old_val.len > 0) { _ = config.freeString(old_val); } } // Config tiene método para trackear strings alocados @field(config.*, v.name) = config.dupeString(value) catch value; } else if (@hasField(ConfigType, "allocator")) { // Usar allocator del config directamente @field(config.*, v.name) = config.allocator.dupe(u8, value) catch value; } else { // Fallback: asignar directamente (puede causar dangling pointer!) @field(config.*, v.name) = value; } }, .color => { 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 { // String que representa un color (nombre de paleta) // También necesita duplicarse if (@hasDecl(ConfigType, "dupeString")) { // Liberar string anterior si existe (evitar leak en hot-reload) if (@hasDecl(ConfigType, "freeString")) { const old_val = @field(config.*, v.name); if (old_val.len > 0) { _ = config.freeString(old_val); } } @field(config.*, v.name) = config.dupeString(value) catch value; } else if (@hasField(ConfigType, "allocator")) { @field(config.*, v.name) = config.allocator.dupe(u8, value) catch value; } else { @field(config.*, v.name) = value; } } }, } } /// Lista de variables disponibles pub fn getVariables() []const ConfigVariable { return variables; } }; } /// 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 // ============================================================================= fn currentMilliTimestamp() i64 { const ts = std.posix.clock_gettime(std.posix.CLOCK.REALTIME) catch return 0; return ts.sec * 1000 + @divTrunc(ts.nsec, 1_000_000); } /// Carga configuracion desde archivo. /// Requiere que ConfigType tenga un campo `allocator`. pub fn load( io: std.Io, comptime variables: []const ConfigVariable, comptime ConfigType: type, config: *ConfigType, path: []const u8, ) !void { const EngineType = Engine(variables, ConfigType); // Leer archivo completo const file = try std.Io.Dir.cwd().openFile(io, path, .{}); defer file.close(io); const stat = try file.stat(io); const content = try config.allocator.alloc(u8, @intCast(stat.size)); defer config.allocator.free(content); _ = try file.readPositional(io, &.{content}, 0); // 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 ':' 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 (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 1 espacio antes (cambiado de 2 a 1) if (i >= 1 and text[i - 1] == ' ') { return i - 1; } if (i >= 1 and text[i - 1] == '\t') { return i - 1; } } } return null; } /// Guarda configuracion a archivo pub fn save( io: std.Io, comptime variables: []const ConfigVariable, comptime ConfigType: type, config: *const ConfigType, path: []const u8, comptime app_name: []const u8, ) !void { const EngineType = Engine(variables, ConfigType); const file = try std.Io.Dir.cwd().createFile(io, path, .{}); defer file.close(io); // Header del archivo try file.writeStreamingAll(io, "# ============================================================================\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.writeStreamingAll(io, header_line); try file.writeStreamingAll(io, "# ============================================================================\n"); try file.writeStreamingAll(io, "# \n"); try file.writeAll(io, &.{ "# Este archivo se genera automaticamente. Puedes editarlo manualmente.\n", "# Formato: @variable: valor # descripcion\n", "# \n", "\n" }); var current_category: ?usize = null; var value_buf: [256]u8 = undefined; var line_buf: [512]u8 = undefined; inline for (variables) |v| { // 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.writeStreamingAll(io, "\n"); } // Si la variable tiene header personalizado, usarlo if (v.category_header) |header| { try file.writeStreamingAll(io, header); try file.writeStreamingAll(io, "\n"); } current_category = v.category; } // Obtener valor actual 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; // Verificar si tiene descripciones extendidas (formato "opcion=desc,opcion2=desc2") const has_descriptions = if (v.auto_validate) |valid| std.mem.indexOf(u8, valid, "=") != null else false; if (has_descriptions) { // Formato extendido: comentarios multilínea // # Descripción de la variable // # Opcion1: Descripción 1 // # Opcion2: Descripción 2 // @variable: valor var desc_line: [256]u8 = undefined; const desc_text = std.fmt.bufPrint(&desc_line, "# {s}\n", .{v.description}) catch ""; try file.writeStreamingAll(io, desc_text); // Parsear opciones con descripciones var options_iter = std.mem.splitScalar(u8, v.auto_validate.?, ','); while (options_iter.next()) |option_desc| { if (std.mem.indexOf(u8, option_desc, "=")) |eq_pos| { const opt_name = option_desc[0..eq_pos]; const opt_desc = option_desc[eq_pos + 1 ..]; const opt_line = std.fmt.bufPrint(&desc_line, "# {s}: {s}\n", .{ opt_name, opt_desc }) catch ""; try file.writeStreamingAll(io, opt_line); } } // Escribir variable sin comentario inline const line = std.fmt.bufPrint(&line_buf, "{s}: {s}\n", .{ v.config_key, value, }) catch unreachable; try file.writeStreamingAll(io, line); } else { // Formato simple: comentario inline con opciones entre corchetes var comment_buf: [256]u8 = undefined; const comment: []const u8 = if (v.auto_validate) |valid| std.fmt.bufPrint(&comment_buf, "{s} [{s}]", .{ v.description, valid }) catch v.description else v.description; if (total_len < 40 and comment.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), comment, }) catch unreachable; try file.writeStreamingAll(io, line); } else { const line = std.fmt.bufPrint(&line_buf, "{s}: {s}\n", .{ v.config_key, value, }) catch unreachable; try file.writeStreamingAll(io, line); } } } try file.writeStreamingAll(io, "\n"); try file.writeStreamingAll(io, "# ============================================================================\n"); try file.writeStreamingAll(io, "# Fin del archivo de configuracion\n"); try file.writeStreamingAll(io, "# ============================================================================\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)]; } fn currentTimestamp() i64 { const ts = std.posix.clock_gettime(std.posix.CLOCK.REALTIME) catch return 0; return ts.sec; } /// Crea backup del archivo de configuracion pub fn createBackup(io: std.Io, path: []const u8) !void { const now = currentTimestamp(); var backup_path_buf: [512]u8 = undefined; const backup_path = std.fmt.bufPrint(&backup_path_buf, "{s}.{d}.bak", .{ path, now }) catch return; const my_cwd = std.Io.Dir.cwd(); my_cwd.copyFile(my_cwd, path, my_cwd, backup_path, io, .{}) 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, }; 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( io: std.Io, 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(io); return self; } /// Inicializa el ConfigManager SIN cargar/crear archivo /// - Crea Config con valores por defecto del struct /// - NO carga archivo, NO crea archivo /// - Útil cuando se necesita control manual (ej: cargar de BD primero) /// - Después llamar load() o save() manualmente pub fn initDeferred( allocator: std.mem.Allocator, file_path: []const u8, ) Self { return Self{ .allocator = allocator, .config = ConfigType.init(allocator), .file_path = file_path, .dirty = false, .observers = .{}, }; } /// Libera recursos y guarda si hay cambios pendientes pub fn deinit(self: *Self, io: std.Io) void { // Auto-save si hay cambios if (self.dirty) { self.save(io) 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); } /// Establece un valor SIN notificar observers /// Útil para carga masiva desde BD (evita writes innecesarios) pub fn setSilent(self: *Self, key: []const u8, value: []const u8) ConfigError!void { try EngineType.set(&self.config, key, value); self.dirty = true; } /// 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, io: std.Io) !void { try loadFn(io, variables, ConfigType, &self.config, self.file_path); self.dirty = false; } /// Guarda configuracion a archivo pub fn save(self: *Self, io: std.Io) !void { try saveFn(io, 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, io: std.Io) !void { self.load(io) catch |err| { if (err == error.FileNotFound) { // Archivo no existe - guardar defaults try self.save(io); 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; // ============================================================================= // FILE WATCHER // ============================================================================= /// Observa un archivo para detectar cambios (via mtime) /// /// Uso tipico: /// ```zig /// var watcher = FileWatcher.init("config.txt", 1000); // check cada 1s /// // En main loop: /// if (watcher.checkForChanges()) { /// // Recargar configuracion /// } /// ``` pub const FileWatcher = struct { path: []const u8, last_mtime: i96 = 0, check_interval_ms: i64 = 1000, last_check: i64 = 0, /// Inicializa un FileWatcher para el path dado /// @param path: ruta del archivo a observar /// @param check_interval_ms: intervalo minimo entre verificaciones (default 1000ms) pub fn init(path: []const u8, check_interval_ms: i64) FileWatcher { return .{ .path = path, .check_interval_ms = check_interval_ms, }; } /// Verifica si el archivo ha cambiado desde la ultima verificacion /// Respeta el intervalo minimo entre verificaciones /// @return true si el archivo cambio, false si no o si hubo error pub fn checkForChanges(self: *FileWatcher, io: std.Io) bool { const now = currentMilliTimestamp(); // Respetar intervalo minimo if (now - self.last_check < self.check_interval_ms) { return false; } self.last_check = now; // Obtener mtime del archivo const stat = std.Io.Dir.cwd().statFile(io, self.path, .{}) catch { return false; }; const file_mtime = stat.mtime.nanoseconds; // Primera vez - guardar mtime inicial if (self.last_mtime == 0) { self.last_mtime = file_mtime; return false; } // Verificar si cambio if (file_mtime != self.last_mtime) { self.last_mtime = file_mtime; return true; } return false; } /// Reinicia el watcher (olvida el mtime anterior) pub fn reset(self: *FileWatcher) void { self.last_mtime = 0; self.last_check = 0; } /// Fuerza actualizar el mtime sin detectar cambio /// Util despues de guardar el archivo pub fn updateMtime(self: *FileWatcher, io: std.Io) void { const stat = std.Io.Dir.cwd().statFile(io, self.path, .{}) catch return; self.last_mtime = stat.mtime.nanoseconds; } }; // ============================================================================= // LOAD FROM STRING // ============================================================================= /// Carga configuracion desde un string (util para defaults embebidos) /// Similar a load() pero sin leer archivo pub fn loadFromString( comptime variables: []const ConfigVariable, comptime ConfigType: type, config: *ConfigType, content: []const u8, ) void { const EngineType = Engine(variables, ConfigType); // Parsear linea por linea (igual que load()) 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 ':' 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 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 { continue; }; } } // ============================================================================= // TESTS // ============================================================================= test "Color parse and format" { const c = Color.parse("RGB(255,128,64)").?; 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); } 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 - negative integer range" { // Rango con mínimo negativo: "-100-100" const neg_var = ConfigVariable{ .name = "test_neg", .config_key = "@test_neg", .var_type = .integer, .default = "0", .auto_validate = "-100-100", .description = "Test negative range", }; try std.testing.expect(neg_var.validate("0")); try std.testing.expect(neg_var.validate("-100")); try std.testing.expect(neg_var.validate("100")); try std.testing.expect(neg_var.validate("-50")); try std.testing.expect(neg_var.validate("50")); try std.testing.expect(!neg_var.validate("-101")); try std.testing.expect(!neg_var.validate("101")); } 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", .config_key = "@enabled", .var_type = .boolean, .default = "Si", .description = "Activar funcionalidad", }, .{ .name = "count", .config_key = "@count", .var_type = .integer, .default = "10", .description = "Contador", .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); var config = TestConfig{}; // Test get const enabled_result = try TestEngine.get(&config, "@enabled"); try std.testing.expectEqual(true, enabled_result.boolean); 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 TestEngine.set(&config, "@count", "42"); try std.testing.expectEqual(@as(i32, 42), config.count); // 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 "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)); } test "FileWatcher init" { const watcher = FileWatcher.init("test.txt", 500); try std.testing.expectEqualStrings("test.txt", watcher.path); try std.testing.expectEqual(@as(i64, 500), watcher.check_interval_ms); try std.testing.expectEqual(@as(i96, 0), watcher.last_mtime); } test "loadFromString basic" { const test_vars = [_]ConfigVariable{ .{ .name = "enabled", .config_key = "@enabled", .var_type = .boolean, .default = "Si", .description = "Test", }, .{ .name = "count", .config_key = "@count", .var_type = .integer, .default = "10", .description = "Test", }, }; const TestConfig = struct { enabled: bool = true, count: i32 = 10, }; var config = TestConfig{}; const content = \\# Comentario \\@enabled: No \\@count: 42 # inline comment ; loadFromString(&test_vars, TestConfig, &config, content); try std.testing.expectEqual(false, config.enabled); try std.testing.expectEqual(@as(i32, 42), config.count); }