libs/external-sync.lua

425 lines

1-- {{{ external-sync.lua
2-- Issue 10-003b: Centralized external file syncing module
3-- Issue 10-026: Now reads from sources-loader (unified config)
4--
5-- Reads external sync info from config.sources via sources-loader.
6-- Falls back to config.external_files for backward compatibility.
7--
8-- All external file pulling should go through this module.
9-- No hardcoded external paths should exist in scripts.
10--
11-- Usage:
12-- local sync = require("external-sync")
13-- sync.set_project_root("/path/to/project")
14-- sync.sync_all() -- Sync all sources
15-- sync.sync_by_name("bluesky-car") -- Sync specific source
16-- sync.list_sources() -- List configured sources
17-- }}}
18
19local M = {}
20
21-- {{{ Module state
22local project_root = nil
23local config = nil
24local verbose = true
25local sources_loader = nil -- Issue 10-026: lazy-loaded sources-loader
26-- }}}
27
28-- {{{ ANSI color codes for terminal output
29local COLOR_GREEN = "\027[92m"
30local COLOR_BLUE = "\027[94m"
31local COLOR_RED = "\027[91m"
32local COLOR_YELLOW = "\027[93m"
33local COLOR_RESET = "\027[0m"
34-- }}}
35
36-- {{{ set_project_root
37-- Set the project root directory (required before any operations)
38function M.set_project_root(path)
39 project_root = path
40 config = nil -- Reset config when root changes
41 -- Issue 10-026: Also initialize sources-loader
42 if not sources_loader then
43 local ok, loader = pcall(require, "sources-loader")
44 if ok then
45 sources_loader = loader
46 end
47 end
48 if sources_loader then
49 sources_loader.set_project_root(path)
50 end
51end
52-- }}}
53
54-- {{{ set_verbose
55-- Enable or disable verbose output
56function M.set_verbose(enabled)
57 verbose = enabled
58end
59-- }}}
60
61-- {{{ local function log
62-- Print a message if verbose mode is enabled
63local function log(msg)
64 if verbose then
65 print(msg)
66 end
67end
68-- }}}
69
70-- {{{ local function log_success
71local function log_success(msg)
72 if verbose then
73 print(COLOR_GREEN .. msg .. COLOR_RESET)
74 end
75end
76-- }}}
77
78-- {{{ local function log_error
79local function log_error(msg)
80 io.stderr:write(COLOR_RED .. msg .. COLOR_RESET .. "\n")
81end
82-- }}}
83
84-- {{{ local function log_warning
85local function log_warning(msg)
86 if verbose then
87 print(COLOR_YELLOW .. msg .. COLOR_RESET)
88 end
89end
90-- }}}
91
92-- {{{ local function load_config
93-- Load config.lua if not already loaded
94local function load_config()
95 if config then
96 return config
97 end
98
99 if not project_root then
100 error("external-sync: project_root not set. Call set_project_root() first.")
101 end
102
103 local config_path = project_root .. "/config.lua"
104 local ok, result = pcall(dofile, config_path)
105 if not ok then
106 error("external-sync: Failed to load config.lua: " .. tostring(result))
107 end
108
109 config = result
110 return config
111end
112-- }}}
113
114-- {{{ local function get_external_files
115-- Issue 10-026: Get external sync entries, preferring sources-loader
116-- Falls back to config.external_files for backward compatibility
117local function get_external_files()
118 -- Try sources-loader first (unified config)
119 if sources_loader then
120 local syncs = sources_loader.get_all_external_syncs()
121 if #syncs > 0 then
122 return syncs
123 end
124 end
125
126 -- Fall back to legacy external_files section
127 local cfg = load_config()
128 local legacy = cfg.external_files or {}
129 -- Add is_archive field for compatibility (detect by .zip extension)
130 for _, entry in ipairs(legacy) do
131 if entry.is_archive == nil then
132 entry.is_archive = entry.source and entry.source:match("%.zip$") ~= nil
133 end
134 end
135 return legacy
136end
137-- }}}
138
139-- {{{ local function path_exists
140-- Check if a path exists (file or directory)
141local function path_exists(path)
142 local handle = io.popen("test -e '" .. path .. "' && echo yes || echo no")
143 local result = handle:read("*l")
144 handle:close()
145 return result == "yes"
146end
147-- }}}
148
149-- {{{ local function is_directory
150-- Check if a path is a directory (vs a file)
151local function is_directory(path)
152 local handle = io.popen("test -d '" .. path .. "' && echo yes || echo no")
153 local result = handle:read("*l")
154 handle:close()
155 return result == "yes"
156end
157-- }}}
158
159-- {{{ local function count_files
160-- Count files in a directory (returns 1 for a single file)
161local function count_files(path)
162 if not path_exists(path) then
163 return 0
164 end
165 if not is_directory(path) then
166 return 1 -- Single file
167 end
168 local handle = io.popen("find '" .. path .. "' -type f 2>/dev/null | wc -l")
169 local result = handle:read("*l")
170 handle:close()
171 return tonumber(result) or 0
172end
173-- }}}
174
175-- {{{ local function run_rsync
176-- Run rsync to sync source to destination
177-- Handles both files and directories
178-- Returns: success (bool), files_synced (number)
179local function run_rsync(source_path, dest_path, options)
180 options = options or {}
181
182 -- Build rsync command
183 local rsync_opts = "-a" -- Archive mode (preserves permissions, timestamps, etc.)
184
185 if not options.overwrite then
186 rsync_opts = rsync_opts .. " --ignore-existing"
187 end
188
189 -- Ensure destination directory exists
190 os.execute("mkdir -p '" .. dest_path .. "'")
191
192 -- Count files before
193 local before_count = count_files(dest_path)
194
195 local cmd
196 if is_directory(source_path) then
197 -- Directory: trailing slash on source means "contents of"
198 cmd = string.format("rsync %s '%s/' '%s/' 2>/dev/null",
199 rsync_opts, source_path, dest_path)
200 else
201 -- File: copy file into destination directory (no trailing slash on source)
202 cmd = string.format("rsync %s '%s' '%s/' 2>/dev/null",
203 rsync_opts, source_path, dest_path)
204 end
205 local result = os.execute(cmd)
206
207 -- Count files after
208 local after_count = count_files(dest_path)
209 local files_synced = after_count - before_count
210
211 -- os.execute returns different values in different Lua versions
212 local success = (result == 0) or (result == true)
213
214 return success, files_synced
215end
216-- }}}
217
218-- {{{ sync_source
219-- Sync a single external source entry
220-- Returns: { success = bool, name = string, files_synced = number, message = string }
221function M.sync_source(source_entry)
222 if not project_root then
223 error("external-sync: project_root not set. Call set_project_root() first.")
224 end
225
226 local name = source_entry.name or "unnamed"
227 local source_path = source_entry.source
228 local destination = source_entry.destination
229
230 -- Validate required fields
231 if not source_path then
232 return {
233 success = false,
234 name = name,
235 files_synced = 0,
236 message = "Missing 'source' field"
237 }
238 end
239
240 -- destination can be empty string "" (meaning input/ root), but not nil
241 if destination == nil then
242 return {
243 success = false,
244 name = name,
245 files_synced = 0,
246 message = "Missing 'destination' field"
247 }
248 end
249
250 -- Build full destination path (relative to input/)
251 -- Empty destination "" means input/ directory itself
252 local full_dest = project_root .. "/input"
253 if destination ~= "" then
254 full_dest = full_dest .. "/" .. destination
255 end
256
257 -- Every external source is mandatory (the "optional" concept was removed): a
258 -- missing source is a hard failure so we notice and fix it, rather than quietly
259 -- shipping without that data.
260 if not path_exists(source_path) then
261 return {
262 success = false,
263 name = name,
264 files_synced = 0,
265 message = "Required source not found: " .. source_path
266 }
267 end
268
269 -- Run rsync
270 local success, files_synced = run_rsync(source_path, full_dest, {
271 overwrite = false -- Don't overwrite existing files
272 })
273
274 if success then
275 return {
276 success = true,
277 name = name,
278 files_synced = files_synced,
279 message = files_synced > 0
280 and string.format("Synced %d new files", files_synced)
281 or "Up to date"
282 }
283 else
284 return {
285 success = false,
286 name = name,
287 files_synced = 0,
288 message = "rsync failed"
289 }
290 end
291end
292-- }}}
293
294-- {{{ sync_by_name
295-- Sync a specific source by name
296-- Returns: result table or nil if not found
297function M.sync_by_name(name)
298 local sources = get_external_files()
299
300 for _, source in ipairs(sources) do
301 if source.name == name then
302 local result = M.sync_source(source)
303
304 -- Log result
305 if result.success then
306 if result.skipped then
307 log_warning("⚠️ " .. name .. ": " .. result.message)
308 else
309 log_success("✅ " .. name .. ": " .. result.message)
310 end
311 else
312 log_error("❌ " .. name .. ": " .. result.message)
313 end
314
315 return result
316 end
317 end
318
319 log_error("❌ Source not found: " .. name)
320 return nil
321end
322-- }}}
323
324-- {{{ sync_all
325-- Sync all configured external sources
326-- Returns: { total = n, synced = n, skipped = n, failed = n, results = {} }
327function M.sync_all()
328 local sources = get_external_files()
329 local results = {
330 total = #sources,
331 synced = 0,
332 skipped = 0,
333 failed = 0,
334 results = {}
335 }
336
337 if #sources == 0 then
338 log("📁 No external sources configured")
339 return results
340 end
341
342 log("📁 Syncing " .. #sources .. " external sources...")
343
344 for _, source in ipairs(sources) do
345 local result = M.sync_source(source)
346 table.insert(results.results, result)
347
348 if result.success then
349 if result.skipped then
350 results.skipped = results.skipped + 1
351 log_warning(" ⚠️ " .. result.name .. ": " .. result.message)
352 else
353 results.synced = results.synced + 1
354 log_success(" ✅ " .. result.name .. ": " .. result.message)
355 end
356 else
357 results.failed = results.failed + 1
358 log_error(" ❌ " .. result.name .. ": " .. result.message)
359 end
360 end
361
362 -- Summary
363 if results.failed > 0 then
364 log_error(string.format("📁 Sync complete: %d synced, %d skipped, %d FAILED",
365 results.synced, results.skipped, results.failed))
366 else
367 log_success(string.format("📁 Sync complete: %d synced, %d skipped",
368 results.synced, results.skipped))
369 end
370
371 return results
372end
373-- }}}
374
375-- {{{ list_sources
376-- List all configured external sources
377-- Returns: array of { name, source, destination }
378function M.list_sources()
379 local sources = get_external_files()
380 local list = {}
381
382 for _, source in ipairs(sources) do
383 table.insert(list, {
384 name = source.name or "unnamed",
385 source = source.source or "",
386 destination = source.destination or ""
387 })
388 end
389
390 return list
391end
392-- }}}
393
394-- {{{ print_sources
395-- Print a formatted list of configured sources
396function M.print_sources()
397 local sources = M.list_sources()
398
399 if #sources == 0 then
400 print("No external sources configured")
401 return
402 end
403
404 print("External sources (" .. #sources .. "):")
405 print(string.rep("-", 70))
406
407 for _, source in ipairs(sources) do
408 print(string.format(" %s", source.name))
409 print(string.format(" FROM: %s", source.source))
410 -- Show "input/" for empty destination, otherwise "input/{dest}"
411 local dest_display = source.destination == "" and "input/" or ("input/" .. source.destination)
412 print(string.format(" TO: %s", dest_display))
413 end
414end
415-- }}}
416
417-- {{{ has_failures
418-- Check if sync_all result has any failures (for exit code)
419function M.has_failures(results)
420 return results.failed > 0
421end
422-- }}}
423
424return M
425