• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nightconcept / almandine / 14846535458

05 May 2025 09:22PM UTC coverage: 32.421% (-35.5%) from 67.965%
14846535458

push

github

web-flow
fix: Change init module to be e2e testable (#17)

91 of 420 new or added lines in 11 files covered. (21.67%)

462 of 1425 relevant lines covered (32.42%)

1.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.44
/src/modules/add.lua
1
--[[
2
  add
3
  @module add
4

5
  Provides functionality to add a dependency to the project manifest and download it to a designated directory.
6
]]
7

8
local filesystem_utils = require("utils.filesystem")
2✔
9
local url_utils = require("utils.url")
2✔
10

11
---@class AddDeps
12
---@field load_manifest fun(): table, string?
13
---@field save_manifest fun(manifest: table): boolean, string?
14
---@field ensure_lib_dir fun(): nil
15
---@field downloader table
16
---@field downloader.download fun(url: string, path: string, verbose: boolean|nil): boolean, string?
17
---@field hash_utils table
18
-- luacheck: ignore
19
---@field hash_utils.hash_file_sha256 fun(path: string): string?, string?, boolean?, string? -- hash, fatal_err, warning_occurred, warning_msg
20
---@field lockfile table
21
---@field lockfile.generate_lockfile_table fun(deps: table): table
22
---@field lockfile.write_lockfile fun(table): boolean, string?
23
---@field lockfile.load_lockfile fun(): table?, string?
24
---@field verbose boolean|nil Whether to run downloader in verbose mode.
25
---@field printer table Printer utility with stdout/stderr methods.
26

27
---@param dep_name string|nil Dependency name to add. If nil, inferred from source URL.
28
---@param dep_source string|table Dependency source string (URL) or table with url/path.
29
---@param dest_dir string|nil Optional destination directory or full path provided via command line (-d).
30
---@param deps AddDeps Table containing dependency injected functions and configuration.
31
---@return boolean success True if core operation completed successfully (download, manifest update).
32
---@return string? error Error message if a fatal operation failed.
33
---@return boolean warning True if a non-fatal warning occurred (e.g. hash failure).
34
---@return string? warning_message The warning message if a warning occurred.
35
---@return boolean success True if successful, false otherwise.
36
---@return string|nil output_message Message for stdout.
37
---@return string|nil error_message Message for stderr.
38
local function add_dependency(dep_name, dep_source, dest_dir, deps)
39
  local verbose = deps.verbose or false
5✔
40
  local output_messages = {}
5✔
41
  local error_messages = {}
5✔
42

43
  -- TODO: Standardize the number of returns. Have the return state continually update so that it's obvious what we are returning
44
  -- in each return statement.
45

46
  -- TODO: Cleanup the return statements so that most of them show only if verbose. Only the final states should really be shown to the Update staged for next run.
47
  -- Compare with pnpm to see what it shows in all these cases.
48

49
  -- Load Manifest & Ensure Library Directory
50
  deps.ensure_lib_dir()
5✔
51
  local manifest, manifest_err = deps.load_manifest()
5✔
52
  if not manifest then
5✔
53
    -- No need to print here, main.lua handles printing errors
NEW
54
    return false, nil, "Error loading manifest: " .. (manifest_err or "Unknown error")
×
55
  end
56

57
  -- Process Input Source (URL/Table)
58
  local input_url
59
  local input_path -- Path provided explicitly in dep_source table (rare)
60

61
  if type(dep_source) == "table" then
5✔
62
    input_url = dep_source.url
×
63
    input_path = dep_source.path -- Path from source table, not -d flag
×
64
    if not input_url then
×
NEW
65
      return false, nil, "Dependency source table must contain a 'url' field."
×
66
    end
67
  else
68
    input_url = dep_source
5✔
69
    input_path = nil
5✔
70
  end
71

72
  -- Determine Dependency Name and Filename
73
  local filename
74
  if dep_name then
5✔
75
    -- Use provided name (-n)
76
    filename = dep_name .. ".lua"
1✔
77
  else
78
    -- Infer filename from URL
79
    filename = input_url:match("/([^/]+)$")
4✔
80
    if not filename or filename == "" then
4✔
NEW
81
      return false, nil, "Could not determine filename from URL: " .. input_url .. ". Try using the -n flag."
×
82
    end
83
    -- Infer dep_name from filename if not provided
84
    local name_from_file = filename:match("^(.+)%.lua$")
4✔
85
    if name_from_file then
4✔
86
      dep_name = name_from_file
4✔
87
    else
88
      -- This case might be hard to reach if filename extraction worked, but safeguarding.
NEW
89
      return false, nil, "Could not infer dependency name from URL/filename: " .. input_url
×
90
    end
91
  end
92
  -- At this point, both dep_name and filename should be set.
93

94
  -- Normalize URL & Get Download Info
95
  -- Returns: download_url, error
96
  local _, _, commit_hash, download_url, norm_err = url_utils.normalize_github_url(input_url)
5✔
97
  if norm_err then
5✔
NEW
98
    return false, nil, string.format("Failed to process URL '%s': %s", input_url, norm_err)
×
99
  end
100
  if not download_url then
5✔
101
    -- TODO: Should be caught by normalize_github_url errors usually, but double-check
NEW
102
    return false, nil, string.format("Could not determine download URL for '%s'", input_url)
×
103
  end
104

105
  -- Create Source Identifier for Manifest
106
  local source_identifier, sid_err = url_utils.create_github_source_identifier(input_url)
5✔
107
  if not source_identifier then
5✔
108
    -- Treat this as a non-fatal warning for now, allows proceeding with original URL
NEW
109
    local warn_msg = string.format(
×
110
      "Could not create specific GitHub identifier for '%s' (%s). Using original URL.",
111
      input_url,
112
      sid_err or "unknown error"
×
113
    )
NEW
114
    table.insert(error_messages, "Warning: " .. warn_msg)
×
115
    source_identifier = input_url -- Fallback to the original URL
×
116
  end
117

118
  -- Determine Final Target Path
119
  local target_path
120
  if dest_dir then
5✔
121
    -- User provided -d flag
122
    local ends_with_sep = dest_dir:match("[/]$")
2✔
123
    local path_type = filesystem_utils.get_path_type(dest_dir)
2✔
124

125
    if path_type == "directory" or ends_with_sep then
2✔
126
      local dir_to_join = dest_dir:gsub("[/\\]$", "") -- Remove trailing / or \
2✔
127
      target_path = filesystem_utils.join_path(dir_to_join, filename)
2✔
128
    else
129
      target_path = dest_dir -- Treat as full path
×
130
    end
131
  elseif input_path then
3✔
132
    -- Path provided via table source (rare)
133
    target_path = input_path
×
134
  else
135
    -- Default path
136
    target_path = filesystem_utils.join_path("src", "lib", filename)
3✔
137
  end
138

139
  -- Normalize path with forward slashes for project.lua
140
  local normalized_path = filesystem_utils.normalize_path(target_path)
5✔
141

142
  -- Ensure Target Directory Exists
143
  local target_dir_path = target_path:match("(.+)[\\/]")
5✔
144
  if target_dir_path then
5✔
145
    local dir_ok, dir_err = filesystem_utils.ensure_dir_exists(target_dir_path)
5✔
146
    if not dir_ok then
5✔
147
      local err_msg =
148
        string.format("Failed to ensure target directory '%s' exists: %s", target_dir_path, dir_err or "unknown error")
×
NEW
149
      return false, nil, err_msg
×
150
    end
151
  end
152

153
  -- Prepare Manifest Update (but don't save yet)
154
  manifest.dependencies = manifest.dependencies or {}
5✔
155
  manifest.dependencies[dep_name] = {
5✔
156
    source = source_identifier,
5✔
157
    path = normalized_path, -- Use normalized path with forward slashes
5✔
158
  }
5✔
159
  table.insert(output_messages,
10✔
160
    string.format("Preparing to add dependency '%s': source='%s', path='%s'", dep_name, source_identifier, normalized_path)
5✔
161
  )
162

163
  -- Download Dependency
164
  table.insert(output_messages, string.format("Downloading '%s' from '%s' to '%s'...", dep_name, download_url, target_path))
5✔
165
  local download_ok, download_err = deps.downloader.download(download_url, target_path, verbose)
5✔
166

167
  if not download_ok then
5✔
168
    table.insert(error_messages, string.format("Error: Failed to download '%s'.", dep_name))
1✔
169
    table.insert(error_messages, "  URL: " .. download_url)
1✔
170
    table.insert(error_messages, "  Reason: " .. (download_err or "Unknown error"))
1✔
171
    table.insert(error_messages, "  Manifest and lockfile were NOT updated.")
1✔
172
    -- Attempt to remove potentially partially downloaded file
173
    local removed, remove_err = filesystem_utils.remove_file(target_path)
1✔
174
    if not removed then
1✔
NEW
175
      table.insert(error_messages, string.format(
×
176
        "Warning: Could not remove partially downloaded file '%s': %s",
177
        target_path,
178
        remove_err or "unknown error"
×
179
      ))
180
    end
181
    -- Return combined output/error messages
182
    return false, table.concat(output_messages, "\n"), table.concat(error_messages, "\n")
1✔
183
  end
184

185
  -- Download Succeeded: Save Manifest
186
  table.insert(output_messages, string.format("Successfully downloaded '%s' to '%s'.", dep_name, target_path))
4✔
187
  local ok_save, err_save = deps.save_manifest(manifest)
4✔
188
  if not ok_save then
4✔
189
    -- Critical error: downloaded file exists, but manifest doesn't reflect it.
190
    local err_msg = "Critical Error: Failed to save project.lua after successful download: "
×
191
      .. (err_save or "Unknown error")
×
NEW
192
    table.insert(error_messages, err_msg)
×
NEW
193
    table.insert(error_messages, "  The downloaded file exists at: " .. target_path)
×
NEW
194
    table.insert(error_messages, "  The manifest (project.lua) may be inconsistent.")
×
NEW
195
    return false, table.concat(output_messages, "\n"), table.concat(error_messages, "\n")
×
196
  end
197
  table.insert(output_messages, "Updated project.lua.")
4✔
198

199
  -- Update Lockfile
200
  local existing_lockfile_data, load_err = deps.lockfile.load_lockfile()
4✔
201
  if load_err and not load_err:match("Could not read lockfile") then
4✔
202
    -- Warn but continue, create a new lockfile or overwrite based on current state.
203
    local load_warn_msg = "Could not load existing lockfile to merge changes: " .. tostring(load_err)
×
NEW
204
    table.insert(error_messages, "Warning: " .. load_warn_msg)
×
205
    -- Aggregate warnings
206
    existing_lockfile_data = nil -- Treat as if no lockfile existed
×
207
  end
208

209
  local current_lock_packages = (existing_lockfile_data and existing_lockfile_data.package) or {}
4✔
210

211
  if current_lock_packages[dep_name] then
4✔
NEW
212
    table.insert(output_messages, string.format("Updating existing entry for '%s' in lockfile.", dep_name))
×
213
  else
214
    table.insert(output_messages, "Adding new entry to lockfile...")
4✔
215
  end
216

217
  -- Determine lockfile hash
218
  local lockfile_hash_string
219
  if commit_hash then
4✔
220
    lockfile_hash_string = "commit:" .. commit_hash
3✔
221
    table.insert(output_messages, string.format("Using commit hash '%s' for lockfile entry '%s'.", commit_hash, dep_name))
3✔
222
  else
223
    table.insert(output_messages, string.format("Calculating sha256 content hash for '%s'...", target_path))
1✔
224
    -- Adjusted call to handle 4 return values
225
    local content_hash, hash_fatal_err, hash_warning_occurred, hash_warning_msg = deps.hash_utils.hash_file_sha256(target_path)
1✔
226

227
    if content_hash then
1✔
228
      lockfile_hash_string = "sha256:" .. content_hash
1✔
229
      table.insert(output_messages, string.format("Using content hash '%s' for lockfile entry '%s'.", content_hash, dep_name))
1✔
230
    elseif hash_warning_occurred then
×
231
      -- Specific non-fatal warning: hash tool not found
232
      lockfile_hash_string = "hash_error:tool_not_found"
×
233
      -- Aggregate warning
NEW
234
      table.insert(error_messages, "Warning: " .. (hash_warning_msg or "Hash tool not found"))
×
235
    else
236
      -- Some other fatal error occurred during hashing (file not found, command failed, parse error)
237
      -- Treat as non-fatal warning
238
      lockfile_hash_string = "hash_error:" .. (hash_fatal_err or "unknown_fatal_error")
×
239
      local hash_fail_warn_msg = string.format("Failed to calculate sha256 hash for '%s': %s", target_path, hash_fatal_err or "unknown error")
×
NEW
240
      table.insert(error_messages, "Warning: " .. hash_fail_warn_msg)
×
241
    end
242
  end
243

244
  -- Add/Update lockfile entry
245
  current_lock_packages[dep_name] = {
4✔
246
    -- Use the raw download URL for reproducibility, as it includes the specific ref used.
247
    source = download_url,
4✔
248
    path = normalized_path, -- Use normalized path with forward slashes
4✔
249
    hash = lockfile_hash_string,
4✔
250
  }
4✔
251

252
  -- Generate and write the lockfile
253
  local lockfile_table = deps.lockfile.generate_lockfile_table(current_lock_packages)
4✔
254
  local ok_lock, err_lock = deps.lockfile.write_lockfile(lockfile_table)
4✔
255

256
  if not ok_lock then
4✔
257
    -- Error saving lockfile, but manifest is already saved.
258
    local err_msg = "Error: Failed to write almd-lock.lua: " .. (err_lock or "Unknown error")
×
NEW
259
    table.insert(error_messages, err_msg)
×
NEW
260
    table.insert(error_messages, "  The manifest (project.lua) was updated, but the lockfile update failed.")
×
261
    -- Treat lockfile write failure as fatal
NEW
262
    return false, table.concat(output_messages, "\n"), table.concat(error_messages, "\n")
×
263
  end
264

265
  table.insert(output_messages, "Successfully updated almd-lock.lua.")
4✔
266
  -- Return success, potentially with warnings
267
  -- Combine messages for return
268
  local final_output = table.concat(output_messages, "\n")
4✔
269
  local final_error = nil
4✔
270
  if #error_messages > 0 then
4✔
NEW
271
    final_error = table.concat(error_messages, "\n")
×
272
  end
273

274
  -- Success: return true, output, errors (which act as warnings here)
275
  return true, final_output, final_error
4✔
276
end
277

278
---Prints usage/help information for the `add` command.
279
---@return string Usage string for the add command.
280
local function help_info()
281
  return [[
×
282
Usage: almd add <source> [-d <path>] [-n <name>] [--verbose]
283

284
Adds a dependency to the project manifest (project.lua) and downloads it.
285
Updates the lockfile (almd-lock.lua) with the resolved download URL and hash.
286

287
Arguments:
288
  <source>     URL of the dependency (e.g., GitHub blob/raw URL) or a Lua table string.
289

290
Options:
291
  -d <path>    Destination path. Can be a directory (file saved as <name>.lua inside)
292
               or a full file path (e.g., src/vendor/custom_name.lua).
293
               Defaults to 'src/lib/<name>.lua'.
294
  -n <name>    Specify the dependency name used in project.lua and the default filename.
295
               If omitted, name is inferred from the <source> URL's filename part.
296
  --verbose    Enable verbose output during download.
297

298
Examples:
299
  # Add from GitHub URL, infer name 'mylib'
300
  almd add https://github.com/user/repo/blob/main/mylib.lua
301

302
  # Add with a specific name 'mybetterlib'
303
  almd add https://github.com/user/repo/blob/main/mylib.lua -n mybetterlib
304

305
  # Add to a specific directory 'src/vendor/' (will be saved as src/vendor/mylib.lua)
306
  almd add https://github.com/user/repo/blob/main/mylib.lua -d src/vendor/
307

308
  # Add to a specific file path 'src/ext/differentlib.lua'
309
  almd add https://github.com/user/repo/blob/main/mylib.lua -d src/ext/differentlib.lua
310

311
  # Add from a Gist (name inferred)
312
  almd add https://gist.githubusercontent.com/user/gistid/raw/commithash/mylib.lua
313
]]
×
314
end
315

316
return {
2✔
317
  add_dependency = add_dependency,
2✔
318
  help_info = help_info,
2✔
319
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc