|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +defmodule Opsm.Registries.ErrorLang do |
| 3 | + @moduledoc """ |
| 4 | + error-lang package registry API client. |
| 5 | +
|
| 6 | + error-lang is a language with computational haptics for errors. |
| 7 | + Package manager: error-pkg (Rust) |
| 8 | + Package format: .error packages with haptic metadata |
| 9 | + Registry: https://packages.error-lang.dev (planned) |
| 10 | +
|
| 11 | + For now, uses local/git-based package resolution until registry deployed. |
| 12 | + """ |
| 13 | + |
| 14 | + alias Opsm.Types.{ManifestFormat, ResolvedPackage} |
| 15 | + alias Opsm.Verified.Http, as: VerifiedHttp |
| 16 | + |
| 17 | + @base_url "https://packages.error-lang.dev/api/v1" |
| 18 | + @fallback_mode :git # Until registry deployed |
| 19 | + |
| 20 | + @doc """ |
| 21 | + Fetch package metadata from error-lang registry. |
| 22 | + Falls back to git-based resolution if registry unavailable. |
| 23 | + """ |
| 24 | + def fetch_package(name, version \\ "latest") do |
| 25 | + case @fallback_mode do |
| 26 | + :git -> fetch_from_git(name, version) |
| 27 | + :registry -> fetch_from_registry(name, version) |
| 28 | + end |
| 29 | + end |
| 30 | + |
| 31 | + @doc """ |
| 32 | + Search for packages in error-lang registry. |
| 33 | + """ |
| 34 | + def search(query, opts \\ []) do |
| 35 | + case @fallback_mode do |
| 36 | + :git -> search_git(query, opts) |
| 37 | + :registry -> search_registry(query, opts) |
| 38 | + end |
| 39 | + end |
| 40 | + |
| 41 | + @doc """ |
| 42 | + Check if a package exists. |
| 43 | + """ |
| 44 | + def exists?(name) do |
| 45 | + case @fallback_mode do |
| 46 | + :git -> git_exists?(name) |
| 47 | + :registry -> registry_exists?(name) |
| 48 | + end |
| 49 | + end |
| 50 | + |
| 51 | + @doc """ |
| 52 | + Get all versions of a package. |
| 53 | + """ |
| 54 | + def versions(name) do |
| 55 | + case @fallback_mode do |
| 56 | + :git -> git_versions(name) |
| 57 | + :registry -> registry_versions(name) |
| 58 | + end |
| 59 | + end |
| 60 | + |
| 61 | + # Registry mode (future) |
| 62 | + |
| 63 | + defp fetch_from_registry(name, version) do |
| 64 | + url = "#{@base_url}/packages/#{URI.encode(name)}" |
| 65 | + |
| 66 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 67 | + {:ok, body} -> |
| 68 | + target_version = if version == "latest", do: body["latest_version"], else: version |
| 69 | + {:ok, parse_package(body, target_version)} |
| 70 | + |
| 71 | + {:error, :not_found} -> |
| 72 | + {:error, :not_found} |
| 73 | + |
| 74 | + {:error, reason} -> |
| 75 | + {:error, reason} |
| 76 | + end |
| 77 | + end |
| 78 | + |
| 79 | + defp search_registry(query, opts) do |
| 80 | + limit = Keyword.get(opts, :limit, 20) |
| 81 | + url = "#{@base_url}/packages?q=#{URI.encode(query)}&limit=#{limit}" |
| 82 | + |
| 83 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 84 | + {:ok, body} when is_list(body) -> |
| 85 | + {:ok, Enum.map(body, &parse_search_result/1)} |
| 86 | + |
| 87 | + {:error, reason} -> |
| 88 | + {:error, reason} |
| 89 | + end |
| 90 | + end |
| 91 | + |
| 92 | + defp registry_exists?(name) do |
| 93 | + url = "#{@base_url}/packages/#{URI.encode(name)}" |
| 94 | + case VerifiedHttp.get(url, receive_timeout: 5_000) do |
| 95 | + {:ok, _} -> true |
| 96 | + _ -> false |
| 97 | + end |
| 98 | + end |
| 99 | + |
| 100 | + defp registry_versions(name) do |
| 101 | + url = "#{@base_url}/packages/#{URI.encode(name)}/versions" |
| 102 | + |
| 103 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 104 | + {:ok, body} when is_list(body) -> |
| 105 | + {:ok, Enum.map(body, & &1["version"])} |
| 106 | + |
| 107 | + {:error, reason} -> |
| 108 | + {:error, reason} |
| 109 | + end |
| 110 | + end |
| 111 | + |
| 112 | + # Git fallback mode (current) |
| 113 | + |
| 114 | + defp fetch_from_git(name, version) do |
| 115 | + # Try common git patterns |
| 116 | + urls = [ |
| 117 | + "https://github.com/hyperpolymath/#{name}", |
| 118 | + "https://gitlab.com/hyperpolymath/#{name}", |
| 119 | + "https://git.sr.ht/~hyperpolymath/#{name}" |
| 120 | + ] |
| 121 | + |
| 122 | + Enum.find_value(urls, {:error, :not_found}, fn url -> |
| 123 | + case git_fetch(url, version) do |
| 124 | + {:ok, pkg} -> {:ok, pkg} |
| 125 | + _ -> nil |
| 126 | + end |
| 127 | + end) |
| 128 | + end |
| 129 | + |
| 130 | + defp search_git(_query, _opts) do |
| 131 | + # Git search not implemented yet |
| 132 | + {:ok, []} |
| 133 | + end |
| 134 | + |
| 135 | + defp git_exists?(name) do |
| 136 | + urls = [ |
| 137 | + "https://github.com/hyperpolymath/#{name}", |
| 138 | + "https://gitlab.com/hyperpolymath/#{name}" |
| 139 | + ] |
| 140 | + |
| 141 | + Enum.any?(urls, fn url -> |
| 142 | + case VerifiedHttp.get(url, receive_timeout: 5_000) do |
| 143 | + {:ok, _} -> true |
| 144 | + _ -> false |
| 145 | + end |
| 146 | + end) |
| 147 | + end |
| 148 | + |
| 149 | + defp git_versions(name) do |
| 150 | + # For git mode, versions are git tags |
| 151 | + {:ok, ["main", "master"]} # Minimal fallback |
| 152 | + end |
| 153 | + |
| 154 | + defp git_fetch(repo_url, version) do |
| 155 | + # error-lang uses error.toml for manifest |
| 156 | + manifest_url = case version do |
| 157 | + "latest" -> "#{repo_url}/raw/main/error.toml" |
| 158 | + "main" -> "#{repo_url}/raw/main/error.toml" |
| 159 | + "master" -> "#{repo_url}/raw/master/error.toml" |
| 160 | + tag -> "#{repo_url}/raw/#{tag}/error.toml" |
| 161 | + end |
| 162 | + |
| 163 | + case VerifiedHttp.get(manifest_url, receive_timeout: 10_000) do |
| 164 | + {:ok, %{body: body}} -> |
| 165 | + case parse_toml_manifest(body, repo_url, version) do |
| 166 | + {:ok, pkg} -> {:ok, pkg} |
| 167 | + error -> error |
| 168 | + end |
| 169 | + |
| 170 | + _ -> |
| 171 | + {:error, :not_found} |
| 172 | + end |
| 173 | + end |
| 174 | + |
| 175 | + defp parse_toml_manifest(toml_text, repo_url, version) do |
| 176 | + # Simple TOML parser (basic implementation) |
| 177 | + lines = String.split(toml_text, "\n") |
| 178 | + |
| 179 | + # Extract [package] section |
| 180 | + in_package = Enum.reduce(lines, {false, %{}}, fn line, {inside, acc} -> |
| 181 | + cond do |
| 182 | + String.starts_with?(String.trim(line), "[package]") -> |
| 183 | + {true, acc} |
| 184 | + |
| 185 | + String.starts_with?(String.trim(line), "[") -> |
| 186 | + {false, acc} |
| 187 | + |
| 188 | + inside -> |
| 189 | + case String.split(String.trim(line), " = ", parts: 2) do |
| 190 | + [key, value] -> |
| 191 | + clean_value = value |> String.trim("\"") |> String.trim() |
| 192 | + {true, Map.put(acc, String.trim(key), clean_value)} |
| 193 | + _ -> |
| 194 | + {inside, acc} |
| 195 | + end |
| 196 | + |
| 197 | + true -> |
| 198 | + {inside, acc} |
| 199 | + end |
| 200 | + end) |
| 201 | + |> elem(1) |
| 202 | + |
| 203 | + pkg = %ResolvedPackage{ |
| 204 | + package: in_package["name"] || extract_name(repo_url), |
| 205 | + version: version, |
| 206 | + forth: :error_lang, |
| 207 | + registry_url: repo_url, |
| 208 | + tarball_url: "#{repo_url}/archive/#{version}.tar.gz", |
| 209 | + checksum: nil, |
| 210 | + checksum_algo: nil, |
| 211 | + manifest: %ManifestFormat{ |
| 212 | + name: in_package["name"], |
| 213 | + version: version, |
| 214 | + description: in_package["description"], |
| 215 | + license: in_package["license"], |
| 216 | + homepage: repo_url, |
| 217 | + repository: repo_url, |
| 218 | + authors: [in_package["authors"] || "Unknown"], |
| 219 | + keywords: [], |
| 220 | + dependencies: %{}, |
| 221 | + dev_dependencies: %{}, |
| 222 | + source_forth: :error_lang, |
| 223 | + raw_manifest: in_package |
| 224 | + }, |
| 225 | + attestations: [], |
| 226 | + resolved_deps: [] |
| 227 | + } |
| 228 | + |
| 229 | + {:ok, pkg} |
| 230 | + end |
| 231 | + |
| 232 | + defp extract_name(url) do |
| 233 | + url |
| 234 | + |> String.split("/") |
| 235 | + |> List.last() |
| 236 | + |> String.trim() |
| 237 | + end |
| 238 | + |
| 239 | + # Parsers (for future registry mode) |
| 240 | + |
| 241 | + defp parse_package(pkg_data, version) do |
| 242 | + %ResolvedPackage{ |
| 243 | + package: pkg_data["name"], |
| 244 | + version: version, |
| 245 | + forth: :error_lang, |
| 246 | + registry_url: @base_url, |
| 247 | + tarball_url: "#{@base_url}/packages/#{pkg_data["name"]}/#{version}/download", |
| 248 | + checksum: pkg_data["checksum"], |
| 249 | + checksum_algo: :sha256, |
| 250 | + manifest: %ManifestFormat{ |
| 251 | + name: pkg_data["name"], |
| 252 | + version: version, |
| 253 | + description: pkg_data["description"], |
| 254 | + license: pkg_data["license"], |
| 255 | + homepage: pkg_data["homepage"], |
| 256 | + repository: pkg_data["repository"], |
| 257 | + authors: pkg_data["authors"] || [], |
| 258 | + keywords: pkg_data["keywords"] || [], |
| 259 | + dependencies: pkg_data["dependencies"] || %{}, |
| 260 | + dev_dependencies: pkg_data["dev_dependencies"] || %{}, |
| 261 | + source_forth: :error_lang, |
| 262 | + raw_manifest: pkg_data |
| 263 | + }, |
| 264 | + attestations: pkg_data["attestations"] || [], |
| 265 | + resolved_deps: [] |
| 266 | + } |
| 267 | + end |
| 268 | + |
| 269 | + defp parse_search_result(result) do |
| 270 | + %{ |
| 271 | + name: result["name"], |
| 272 | + version: result["latest_version"], |
| 273 | + description: result["description"], |
| 274 | + downloads: result["downloads"] || 0, |
| 275 | + recent_downloads: result["recent_downloads"] || 0 |
| 276 | + } |
| 277 | + end |
| 278 | +end |
0 commit comments