Problema: Al recargar config, los strings asignados apuntaban al buffer de contenido del archivo que se liberaba con defer al final de load(). Esto causaba segfaults al acceder a los valores después. Solución: Usar dupeString() o allocator.dupe() para crear copias independientes de los strings antes de asignarlos a la config. Afecta tipos .string, .string_array y .color (cuando el campo es []const u8). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
967 lines
35 KiB
Zig
967 lines
35 KiB
Zig
//! 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"
|
|
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;
|
|
}
|
|
};
|
|
|
|
/// 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")) {
|
|
// 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")) {
|
|
@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
|
|
// =============================================================================
|
|
|
|
/// Carga configuracion desde archivo.
|
|
/// Requiere que ConfigType tenga un campo `allocator`.
|
|
pub fn load(
|
|
comptime variables: []const ConfigVariable,
|
|
comptime ConfigType: type,
|
|
config: *ConfigType,
|
|
path: []const u8,
|
|
) !void {
|
|
const EngineType = Engine(variables, ConfigType);
|
|
|
|
// 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);
|
|
|
|
// 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 2 espacios antes
|
|
if (i >= 2 and text[i - 1] == ' ' and text[i - 2] == ' ') {
|
|
return i - 2;
|
|
}
|
|
if (i >= 1 and text[i - 1] == '\t') {
|
|
return i - 1;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Guarda configuracion a archivo
|
|
pub fn save(
|
|
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.fs.cwd().createFile(path, .{});
|
|
defer file.close();
|
|
|
|
// 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");
|
|
|
|
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.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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
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,
|
|
};
|
|
|
|
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
|
|
// =============================================================================
|
|
|
|
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 - 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));
|
|
}
|