libs/utils.lua

603 lines

1#!/usr/bin/env lua
2
3-- Project-wide utility library
4-- Common functions for file I/O, logging, and configuration management
5
6local M = {}
7
8-- {{{ local function setup_dir_path
9local function setup_dir_path(provided_dir)
10 if provided_dir then
11 return provided_dir
12 end
13 return "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
14end
15-- }}}
16
17-- Module configuration
18M.DIR = setup_dir_path()
19
20-- {{{ function M.log_info
21function M.log_info(message)
22 print(string.format("[INFO] %s", message))
23end
24-- }}}
25
26-- {{{ function M.log_warn
27function M.log_warn(message)
28 print(string.format("[WARN] %s", message))
29end
30-- }}}
31
32-- {{{ function M.log_error
33function M.log_error(message)
34 print(string.format("[ERROR] %s", message))
35end
36-- }}}
37
38-- {{{ function M.file_exists
39function M.file_exists(filepath)
40 local file = io.open(filepath, "r")
41 if file then
42 file:close()
43 return true
44 end
45 return false
46end
47-- }}}
48
49-- {{{ function M.read_file
50function M.read_file(filepath)
51 local file = io.open(filepath, "r")
52 if not file then
53 return nil, "Could not open file: " .. filepath
54 end
55
56 local content = file:read("*all")
57 file:close()
58 return content
59end
60-- }}}
61
62-- {{{ function M.write_file
63function M.write_file(filepath, content)
64 local file = io.open(filepath, "w")
65 if not file then
66 return false, "Could not create file: " .. filepath
67 end
68
69 file:write(content)
70 file:close()
71 return true
72end
73-- }}}
74
75-- {{{ function M.get_timestamp
76function M.get_timestamp()
77 return os.date("%Y-%m-%d %H:%M:%S")
78end
79-- }}}
80
81-- {{{ function M.ensure_directory
82function M.ensure_directory(dirpath)
83 local cmd = "mkdir -p " .. dirpath
84 local result = os.execute(cmd)
85 return result == 0 or result == true
86end
87-- }}}
88
89-- {{{ function M.get_project_paths
90function M.get_project_paths(base_dir)
91 base_dir = base_dir or M.DIR
92 return {
93 root = base_dir,
94 src = base_dir .. "/src",
95 libs = base_dir .. "/libs",
96 assets = base_dir .. "/assets",
97 docs = base_dir .. "/docs",
98 notes = base_dir .. "/notes",
99 issues = base_dir .. "/issues"
100 }
101end
102-- }}}
103
104-- {{{ function M.parse_interactive_args
105function M.parse_interactive_args(args)
106 local interactive = false
107 local dir_override = nil
108
109 -- Issue 10-065: index-based loop, so "--dir PATH" can be consumed as a pair.
110 -- Same reasoning as parse_cli_args above: --dir names the ASSETS root and is
111 -- read by init_assets_root(); letting its value fall through to the bare-token
112 -- branch would set dir_override, which callers assign to the PROJECT root.
113 -- Fixed here pre-emptively -- this function has the same shape as the one
114 -- that broke stage 3, and nothing currently passes it --dir only by luck.
115 args = args or {}
116 local i = 1
117 while i <= #args do
118 local a = args[i]
119 if a == "-I" then
120 interactive = true
121 elseif a == "--dir" then
122 i = i + 1 -- skip the flag's value; it is not a project root
123 elseif a:match("^%-%-dir=") then
124 -- Nothing to skip: the value rides on the same token.
125 elseif not a:match("^%-") then
126 -- Non-flag argument, treat as directory override
127 dir_override = a
128 end
129 i = i + 1
130 end
131
132 return interactive, dir_override
133end
134-- }}}
135
136-- {{{ function M.parse_cli_args
137-- Comprehensive CLI argument parser for main.lua
138-- Returns a table with all parsed options for selective stage execution
139-- Supports: stage flags (--parse-only, --validate-only, etc.), config (--force, --threads)
140-- Phase D (Issue 8-012): Added --pages flag for pagination control
141function M.parse_cli_args(args)
142 local options = {
143 interactive = false,
144 dir_override = nil,
145 -- Stage flags (when set, only run specified stages)
146 parse_only = false,
147 validate_only = false,
148 catalog_only = false,
149 html_only = false,
150 -- Config flags
151 force = false,
152 verbose = false, -- Issue 10-015a: Verbose output for detailed statistics
153 threads = nil,
154 pages = nil, -- Phase D (Issue 8-012): Pagination control ("1", "all", "1-10")
155 poems_per_page = nil, -- Issue 8-022: Poems per page override
156 chrono_per_page = nil, -- Issue 9-003: Chronological poems per page override
157 seed = nil, -- Issue 10-058: build master seed (threaded to randomizers)
158 }
159
160 local i = 1
161 while i <= #(args or {}) do
162 local arg = args[i]
163
164 if arg == "-I" or arg == "--interactive" then
165 options.interactive = true
166 elseif arg == "--parse-only" then
167 options.parse_only = true
168 elseif arg == "--validate-only" then
169 options.validate_only = true
170 elseif arg == "--catalog-only" then
171 options.catalog_only = true
172 elseif arg == "--html-only" then
173 options.html_only = true
174 elseif arg == "--force" then
175 options.force = true
176 elseif arg == "--verbose" or arg == "-v" then
177 options.verbose = true
178 elseif arg == "--threads" and args[i + 1] then
179 options.threads = tonumber(args[i + 1])
180 i = i + 1
181 elseif arg:match("^--threads=") then
182 options.threads = tonumber(arg:match("^--threads=(%d+)"))
183 elseif arg == "--pages" and args[i + 1] then
184 options.pages = args[i + 1] -- String value: "1", "all", "1-10"
185 i = i + 1
186 elseif arg:match("^--pages=") then
187 options.pages = arg:match("^--pages=(.+)") -- String value: "1", "all", "1-10"
188 elseif arg == "--poems-per-page" and args[i + 1] then
189 options.poems_per_page = tonumber(args[i + 1]) -- Numeric value: 100, 200, etc.
190 i = i + 1
191 elseif arg:match("^--poems%-per%-page=") then
192 options.poems_per_page = tonumber(arg:match("^--poems%-per%-page=(%d+)"))
193 elseif arg == "--chrono-per-page" and args[i + 1] then
194 options.chrono_per_page = tonumber(args[i + 1]) -- Issue 9-003: Chronological poems per page
195 i = i + 1
196 elseif arg:match("^--chrono%-per%-page=") then
197 options.chrono_per_page = tonumber(arg:match("^--chrono%-per%-page=(%d+)"))
198 -- Issue 10-058: consume --seed (both forms) so the bare numeric value is
199 -- never swallowed by the dir-override branch below (which would point the
200 -- build at a nonexistent directory named after the seed).
201 elseif arg == "--seed" and args[i + 1] then
202 options.seed = tonumber(args[i + 1])
203 i = i + 1
204 elseif arg:match("^--seed=") then
205 options.seed = tonumber(arg:match("^--seed=(%d+)"))
206 -- Issue 10-065: consume "--dir PATH" as a PAIR.
207 --
208 -- The value is deliberately DISCARDED here. --dir names the ASSETS root
209 -- and is read by init_assets_root(), which parses `arg` itself; it must
210 -- never become dir_override, which main.lua assigns to DIR -- the
211 -- PROJECT root. Those are different directories and conflating them
212 -- breaks path resolution downstream.
213 --
214 -- What went wrong without this: the old comment below said "--dir
215 -- handled elsewhere", and the parser did skip the FLAG -- but not its
216 -- VALUE. The path then fell through to the bare-token branch and became
217 -- dir_override, so main.lua set the project root to the assets
218 -- directory. Stage 3 then looked for input/ and compiled.txt underneath
219 -- assets/ and reported "No valid input found". Same shape of bug as the
220 -- three child parsers fixed alongside this one: skipping a flag is not
221 -- the same as skipping a flag and its argument.
222 elseif arg == "--dir" then
223 i = i + 1
224 elseif arg:match("^%-%-dir=") then
225 -- Nothing to skip: the value rides on the same token.
226 elseif not arg:match("^%-") then
227 -- Non-flag argument, treat as directory override (the PROJECT root)
228 options.dir_override = arg
229 end
230 -- Skip remaining unknown flags. A flag that TAKES a value needs its own
231 -- branch above, or its value lands in dir_override.
232
233 i = i + 1
234 end
235
236 return options
237end
238-- }}}
239
240-- {{{ function M.show_menu
241function M.show_menu(title, options)
242 print("\n=== " .. title .. " ===")
243 for i, option in ipairs(options) do
244 print(string.format("%d. %s", i, option))
245 end
246 io.write("Select option (1-" .. #options .. "): ")
247 local choice = tonumber(io.read())
248
249 if choice and choice >= 1 and choice <= #options then
250 return choice
251 else
252 print("Invalid choice")
253 return nil
254 end
255end
256-- }}}
257
258-- {{{ function M.confirm_action
259function M.confirm_action(message)
260 io.write(message .. " (y/N): ")
261 local response = io.read():lower()
262 return response == "y" or response == "yes"
263end
264-- }}}
265
266-- {{{ function M.read_json_file
267function M.read_json_file(filepath)
268 package.path = M.DIR .. "/libs/?.lua;" .. package.path
269 local dkjson = require("dkjson")
270 local content = M.read_file(filepath)
271 if content then
272 local data, pos, err = dkjson.decode(content, 1, nil)
273 if err then
274 M.log_error("JSON decode error in " .. filepath .. ": " .. err)
275 return nil
276 end
277 return data
278 end
279 return nil
280end
281-- }}}
282
283-- {{{ function M.write_json_file
284function M.write_json_file(filepath, data)
285 package.path = M.DIR .. "/libs/?.lua;" .. package.path
286 local dkjson = require("dkjson")
287 local json_string = dkjson.encode(data, { indent = true })
288 if json_string then
289 return M.write_file(filepath, json_string)
290 else
291 M.log_error("Failed to encode JSON data for " .. filepath)
292 return false
293 end
294end
295-- }}}
296
297-- {{{ function M.directory_exists
298function M.directory_exists(dirpath)
299 local cmd = "[ -d '" .. dirpath .. "' ]"
300 local result = os.execute(cmd)
301 return result == 0 or result == true
302end
303-- }}}
304
305-- {{{ function M.get_file_mtime
306function M.get_file_mtime(filepath)
307 local stat_cmd = string.format("stat -c %%Y '%s' 2>/dev/null", filepath)
308 local handle = io.popen(stat_cmd)
309 if handle then
310 local result = handle:read("*a")
311 handle:close()
312 if result and result ~= "" then
313 local clean_result = result:gsub("%s+", "")
314 local timestamp = tonumber(clean_result)
315 return timestamp
316 end
317 end
318 return nil
319end
320-- }}}
321
322-- {{{ function M.get_working_directory
323function M.get_working_directory()
324 local handle = io.popen("pwd")
325 if handle then
326 local result = handle:read("*l")
327 handle:close()
328 return result or M.DIR
329 end
330 return M.DIR
331end
332-- }}}
333
334-- {{{ function M.relative_path
335function M.relative_path(absolute_path, base_dir)
336 -- Convert absolute path to relative path for cleaner output
337 -- Issue 7-003: If path equals base_dir, show project name instead of "./"
338 base_dir = base_dir or M.DIR
339 if absolute_path == base_dir or absolute_path == base_dir .. "/" then
340 -- Return the directory name (e.g., "neocities-modernization/")
341 local dir_name = base_dir:match("([^/]+)/?$")
342 return dir_name .. "/"
343 end
344 if absolute_path:sub(1, #base_dir) == base_dir then
345 local relative = absolute_path:sub(#base_dir + 1)
346 if relative:sub(1, 1) == "/" then
347 relative = relative:sub(2)
348 end
349 return "./" .. relative
350 end
351 return absolute_path
352end
353-- }}}
354
355-- ============================================================================
356-- Asset Path Configuration
357-- Configurable storage for generated assets (embeddings, poems.json, etc.)
358-- ============================================================================
359
360-- Module state for cached asset configuration
361local _assets_root = nil
362local _assets_config_loaded = false
363
364-- {{{ function M.parse_assets_dir
365-- Parse --dir flag from command line arguments
366-- @param args: table of command line arguments (default: global 'arg')
367-- @return: string path if --dir found, nil otherwise
368function M.parse_assets_dir(args)
369 args = args or arg
370 if not args then return nil end
371
372 local i = 1
373 while i <= #args do
374 local arg_val = args[i]
375 if arg_val == "--dir" and args[i + 1] then
376 return args[i + 1]
377 elseif arg_val:match("^%-%-dir=") then
378 return arg_val:match("^%-%-dir=(.+)$")
379 end
380 i = i + 1
381 end
382 return nil
383end
384-- }}}
385
386-- {{{ function M.load_asset_config
387-- Issue 10-003: Load asset path configuration from unified config (config.lua)
388-- @return: table with assets_root key, or nil if config not found
389function M.load_asset_config()
390 -- Use config-loader to get asset_paths from unified config
391 local ok, config_loader = pcall(require, "config-loader")
392 if ok and config_loader then
393 config_loader.set_project_root(M.DIR)
394 local config = config_loader.load()
395 if config and config.asset_paths then
396 return config.asset_paths
397 end
398 end
399 return nil
400end
401-- }}}
402
403-- {{{ function M.init_assets_root
404-- Initialize assets root path with priority: CLI > config > error
405-- Must be called once at startup, before any asset_path() calls
406-- @param cli_args: optional table of CLI arguments (default: global 'arg')
407-- @return: string path to assets root, or nil on error (after printing message)
408function M.init_assets_root(cli_args)
409 -- Check CLI argument first (highest priority)
410 local cli_dir = M.parse_assets_dir(cli_args)
411 if cli_dir then
412 if not M.directory_exists(cli_dir) then
413 io.stderr:write("\n")
414 io.stderr:write("Error: Assets directory not found: " .. cli_dir .. "\n")
415 io.stderr:write("\n")
416 io.stderr:write("Fix: supply valid path via --dir ~/your/assets/path\n")
417 io.stderr:write("\n")
418 io.stderr:write("Expected structure:\n")
419 io.stderr:write(" " .. cli_dir .. "/\n")
420 io.stderr:write(" poems.json\n")
421 io.stderr:write(" embeddings/\n")
422 io.stderr:write(" <model-name>/\n")
423 io.stderr:write(" embeddings.json\n")
424 io.stderr:write("\n")
425 return nil
426 end
427 _assets_root = cli_dir
428 _assets_config_loaded = true
429 return _assets_root
430 end
431
432 -- Try config file (second priority)
433 local config = M.load_asset_config()
434 if config and config.assets_root then
435 if not M.directory_exists(config.assets_root) then
436 io.stderr:write("\n")
437 io.stderr:write("Error: Assets directory not found: " .. config.assets_root .. "\n")
438 io.stderr:write("\n")
439 io.stderr:write("Fix: supply path via --dir ~/your/assets/path\n")
440 io.stderr:write(" or update asset_paths.assets_root in config.lua\n")
441 io.stderr:write("\n")
442 io.stderr:write("Expected structure:\n")
443 io.stderr:write(" " .. config.assets_root .. "/\n")
444 io.stderr:write(" poems.json\n")
445 io.stderr:write(" embeddings/\n")
446 io.stderr:write(" <model-name>/\n")
447 io.stderr:write(" embeddings.json\n")
448 io.stderr:write("\n")
449 return nil
450 end
451 _assets_root = config.assets_root
452 _assets_config_loaded = true
453 return _assets_root
454 end
455
456 -- Fallback to project default (for backward compatibility during transition)
457 local default_path = M.DIR .. "/assets"
458 if M.directory_exists(default_path) then
459 _assets_root = default_path
460 _assets_config_loaded = true
461 return _assets_root
462 end
463
464 -- Nothing found - error
465 io.stderr:write("\n")
466 io.stderr:write("Error: Assets directory not found\n")
467 io.stderr:write("\n")
468 io.stderr:write("Fix: supply path via --dir ~/your/assets/path\n")
469 io.stderr:write("\n")
470 io.stderr:write("Expected structure:\n")
471 io.stderr:write(" ~/your/assets/path/\n")
472 io.stderr:write(" poems.json\n")
473 io.stderr:write(" embeddings/\n")
474 io.stderr:write(" <model-name>/\n")
475 io.stderr:write(" embeddings.json\n")
476 io.stderr:write("\n")
477 return nil
478end
479-- }}}
480
481-- {{{ function M.get_assets_root
482-- Get the configured assets root path
483-- Initializes from config if not already done
484-- @param cli_args: optional CLI args for initialization
485-- @return: string path to assets root
486function M.get_assets_root(cli_args)
487 if not _assets_config_loaded then
488 local result = M.init_assets_root(cli_args)
489 if not result then
490 os.exit(1)
491 end
492 end
493 return _assets_root
494end
495-- }}}
496
497-- {{{ function M.asset_path
498-- Build full path to an asset file
499-- @param relative: relative path within assets (e.g., "poems.json")
500-- @return: full absolute path
501function M.asset_path(relative)
502 return M.get_assets_root() .. "/" .. relative
503end
504-- }}}
505
506-- {{{ function M.embeddings_dir
507-- Get path to embeddings directory for the named model, or for the currently
508-- configured default model when called with no argument.
509--
510-- Centralizing the model -> directory mapping here means a model switch in
511-- config.lua propagates automatically to every caller, instead of requiring
512-- a hunt through ~30 hardcoded "embeddinggemma_latest" string literals.
513-- @param model_name: optional. nil means "ask inference-server-config which model is
514-- currently selected and use that"; pass an explicit
515-- string only if you need a different model's directory.
516-- @return: full path to that model's embeddings directory
517-- WHERE THE CACHES LIVE, and the history of that answer.
518--
519-- Now: on DISK, under assets/embeddings/<model>/. Both this function and
520-- embeddings_dir_disk() resolve there.
521--
522-- Issue 10-054 moved the movable, regenerable caches into RAM
523-- (tmp/shared-memory/, the /dev/shm tier) to spare SSD write endurance, keeping
524-- only diversity_cache.json on disk because it is the expensive one to
525-- recompute. It removed the earlier CACHE_IN_RAM on/off flag rather than
526-- flipping it, on the reasoning that one unconditional location cannot desync
527-- the way a half-applied switch had twice before.
528--
529-- Issue 10-065 (question 10) then found the fact that decision was missing.
530-- "RAM until reboot" was not true on this host: elogind runs with RemoveIPC at
531-- its default of yes, which deletes every IPC object a user owns -- POSIX shared
532-- memory, i.e. the contents of /dev/shm -- when that user's LAST LOGIN SESSION
533-- ends. Closing the last terminal is enough. (/tmp is not an IPC object, which
534-- is why the exec tier survives when the shared-memory tier does not.)
535--
536-- Observed: /dev/shm/neocities-modernization was emptied overnight on a machine
537-- with two days of uptime, taking a 120 MB embeddings.json with it.
538--
539-- So the caches were being discarded per SESSION, not per reboot, and the
540-- ~20-minute rebuild (per .stage-timings) was being paid that often. Reversed
541-- accordingly: see the note on embeddings_dir below.
542--
543-- The rule is unchanged in shape, only in destination: movable caches ->
544-- embeddings_dir(); the one that must never be volatile -> embeddings_dir_disk().
545local function safe_model(model_name)
546 if not model_name then
547 model_name = require("inference-server-config").get_selected_model()
548 end
549 -- Sanitize model name for filesystem safety (e.g. embeddinggemma:latest -> embeddinggemma_latest)
550 return model_name:gsub("[^%w%-_.]", "_")
551end
552
553-- REVERSED 2026-08-08 (Issue 10-065, question 11): this returns the DISK path
554-- again. It read "M.DIR .. /tmp/shared-memory/cache/embeddings/ .." -- the RAM
555-- tier -- from Issue 10-054 until the finding in question 10 undercut the
556-- premise that change rested on.
557--
558-- 10-054 traded durability for SSD write endurance, on the understanding that
559-- what it gave up was survival across a REBOOT. That was wrong about this host.
560-- elogind runs here with RemoveIPC at its default of yes, so /dev/shm is emptied
561-- when the operator's last login session ends -- closing the last terminal is
562-- enough. The caches were not surviving until the next reboot; they were
563-- surviving until the next logout, and a 20-minute rebuild (per .stage-timings)
564-- was being paid per session rather than per reboot. A cache with that lifetime
565-- is a per-session scratch buffer, and paying twenty minutes an evening to avoid
566-- roughly 4 GB of writes per full regeneration is the wrong side of the trade.
567--
568-- The reversal is one line ONLY because 10-054 did the hard part: it routed
569-- every reader AND every writer of a movable cache through this function (that
570-- was the work its own history records as failing twice before it stuck). With
571-- one resolution point, moving the tier is changing one return value. The value
572-- of that centralization is exactly this -- that the decision it encodes can be
573-- revisited cheaply when the facts change.
574function M.embeddings_dir(model_name)
575 return M.asset_path("embeddings/" .. safe_model(model_name))
576end
577-- }}}
578
579-- {{{ function M.embeddings_dir_disk
580-- The on-DISK embeddings dir (assets/). Kept as a SEPARATE function even though
581-- it now returns exactly what embeddings_dir() returns, because the distinction
582-- it draws is real and worth keeping legible: its callers
583-- (diversity_cache.json, in flat-html-generator, main.lua and
584-- pipeline-validator) are the ones that must NEVER be moved to volatile storage,
585-- whatever embeddings_dir does next. Collapsing them would erase the record of
586-- which caches are cheap to lose and which are not -- and that record is the
587-- thing that made the reversal above safe to reason about.
588function M.embeddings_dir_disk(model_name)
589 return M.asset_path("embeddings/" .. safe_model(model_name))
590end
591-- }}}
592
593-- {{{ function M.similarities_dir
594-- Get path to similarities directory for a specific model
595-- @param model_name: optional model name
596-- @return: full path to model's similarities directory
597function M.similarities_dir(model_name)
598 return M.embeddings_dir(model_name) .. "/similarities"
599end
600-- }}}
601
602return M
603