libs/config-loader.lua

200 lines

1-- {{{ libs/config-loader.lua
2-- Issue 10-003: Utility module to load and cache the consolidated config
3-- This module provides a single entry point to access all project configuration.
4-- The config is loaded once and cached for subsequent requires.
5--
6-- Usage:
7-- local config = require("config-loader")
8-- local assets_root = config.asset_paths.assets_root
9-- local colors = config.semantic_colors
10--
11-- If scripts need to override config location:
12-- local config_loader = require("config-loader")
13-- config_loader.set_config_path("/custom/path/to/config.lua")
14-- local config = config_loader.load()
15-- }}}
16
17local M = {}
18
19-- {{{ Configuration
20-- Default config path (relative to project root)
21-- Issue 10-003: Moved from config/main.lua to project root
22local DEFAULT_CONFIG_PATH = "config.lua"
23
24-- Cached config table (loaded once per session)
25local cached_config = nil
26
27-- Custom config path override
28local custom_config_path = nil
29
30-- Project root detection (for relative paths)
31local project_root = nil
32-- }}}
33
34-- {{{ local function detect_project_root
35-- Detect project root by looking for known marker files
36local function detect_project_root()
37 -- Try to find project root from current script location
38 local script_path = debug.getinfo(1, "S").source:sub(2) -- Remove @ prefix
39
40 -- If script is in libs/, go up one level
41 if script_path:match("/libs/") then
42 project_root = script_path:match("(.+)/libs/")
43 elseif script_path:match("\\libs\\") then
44 -- Windows path
45 project_root = script_path:match("(.+)\\libs\\")
46 end
47
48 -- Fallback: check known project directory
49 if not project_root then
50 local known_paths = {
51 "/mnt/mtwo/programming/ai-stuff/neocities-modernization",
52 "/home/ritz/programming/ai-stuff/neocities-modernization"
53 }
54 for _, path in ipairs(known_paths) do
55 -- Issue 10-003: Check for config.lua in project root
56 local f = io.open(path .. "/config.lua", "r")
57 if f then
58 f:close()
59 project_root = path
60 break
61 end
62 end
63 end
64
65 return project_root
66end
67-- }}}
68
69-- {{{ local function resolve_path
70-- Resolve a config path to absolute path
71local function resolve_path(path)
72 -- If already absolute, return as-is
73 if path:match("^/") or path:match("^%a:") then
74 return path
75 end
76
77 -- Resolve relative to project root
78 local root = project_root or detect_project_root()
79 if root then
80 return root .. "/" .. path
81 end
82
83 -- Fallback: return relative path and hope for the best
84 return path
85end
86-- }}}
87
88-- {{{ function M.set_project_root
89-- Override the detected project root
90function M.set_project_root(path)
91 project_root = path
92 -- Clear cache to force reload from new location
93 cached_config = nil
94end
95-- }}}
96
97-- {{{ function M.set_config_path
98-- Override the default config path
99function M.set_config_path(path)
100 custom_config_path = path
101 -- Clear cache to force reload from new path
102 cached_config = nil
103end
104-- }}}
105
106-- {{{ function M.load
107-- Load (or return cached) configuration
108function M.load()
109 -- Return cached config if available
110 if cached_config then
111 return cached_config
112 end
113
114 -- Determine config path
115 local config_path = custom_config_path or DEFAULT_CONFIG_PATH
116 local absolute_path = resolve_path(config_path)
117
118 -- Load config file
119 local chunk, err = loadfile(absolute_path)
120 if not chunk then
121 error(string.format(
122 "config-loader: Failed to load config from %s: %s",
123 absolute_path, err or "unknown error"
124 ))
125 end
126
127 -- Execute and cache
128 local ok, result = pcall(chunk)
129 if not ok then
130 error(string.format(
131 "config-loader: Error executing config %s: %s",
132 absolute_path, result or "unknown error"
133 ))
134 end
135
136 -- Validate result is a table
137 if type(result) ~= "table" then
138 error(string.format(
139 "config-loader: Config file must return a table, got %s",
140 type(result)
141 ))
142 end
143
144 cached_config = result
145 return cached_config
146end
147-- }}}
148
149-- {{{ function M.reload
150-- Force reload configuration (useful for testing or hot-reload scenarios)
151function M.reload()
152 cached_config = nil
153 return M.load()
154end
155-- }}}
156
157-- {{{ function M.get
158-- Get a specific config section or value by dot-notation path
159-- Example: config_loader.get("asset_paths.assets_root")
160function M.get(path)
161 local config = M.load()
162
163 if not path or path == "" then
164 return config
165 end
166
167 local current = config
168 for segment in path:gmatch("[^%.]+") do
169 if type(current) ~= "table" then
170 return nil
171 end
172 current = current[segment]
173 end
174
175 return current
176end
177-- }}}
178
179-- {{{ Metatable for direct table access
180-- Allow using the module directly as a config table:
181-- local config = require("config-loader")
182-- local value = config.asset_paths.assets_root
183setmetatable(M, {
184 __index = function(_, key)
185 local config = M.load()
186 return config[key]
187 end,
188 __pairs = function(_)
189 local config = M.load()
190 return pairs(config)
191 end,
192 __ipairs = function(_)
193 local config = M.load()
194 return ipairs(config)
195 end
196})
197-- }}}
198
199return M
200