/* * * Copyright (C) 2026 Andromeda * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * */ module radapi; import std::io; import std::core::string; import std::collections::pair; import std::collections::object; import std::collections::maybe; import std::encoding::json; import curl; enum Request : int (String path) { DELEGATES_REPOS { "/delegates/{did}/repos" }, NODE { "/node" }, NODE_POLICIES_REPOS { "/node/policies/repos" }, NODE_POLICIES_REPO { "/node/policies/repos/{rid}" }, NODES { "/nodes/{nid}" }, NODES_INVENTORY { "/nodes/{nid}/inventory" }, REPO_ROOT { "/repos" }, REPO_SEARCH { "/repos/search" }, REPO { "/repos/{rid}" }, HISTORY { "/repos/{rid}/commits" }, COMMIT { "/repos/{rid}/commits/{sha}" }, DIFF { "/repos/{rid}/diff/{base}/{oid}" }, ACTIVITY { "/repos/{rid}/activity" }, TREE_ROOT { "/repos/{rid}/tree/{sha}/" }, TREE { "/repos/{rid}/tree/{sha}/{path}" }, STATS_TREE { "/repos/{rid}/stats/tree/{sha}" }, REMOTES { "/repos/{rid}/remotes" }, REMOTE { "/repos/{rid}/remotes/{peer}" }, BLOB { "/repos/{rid}/blob/{sha}/{path}" }, README { "/repos/{rid}/readme/{sha}" }, JOB { "/repos/{rid}/jobs/{sha}" }, ISSUES { "/repos/{rid}/issues" }, ISSUE { "/repos/{rid}/issues/{id}" }, PATCHES { "/repos/{rid}/patches" }, PATCH { "/repos/{rid}/patches/{id}" }, STATS { "/stats" }, } alias ResponseRaw = String; struct SearchRepo { Rid rid; struct payloads { struct xyz { struct radicle { struct project { String defaultBranch; String description; String name; }}} } Delegate[] delegates; int seeds; } struct Repo { struct payloads { struct xyz { struct radicle { struct project { struct data { String defaultBranch; String description; String name; } struct meta { Sha head; struct issues { int open; int closed; } struct patches { int open; int draft; int archived; int merged; } } }}} } Delegate[] delegates; int threshold; struct visibility { String type; } Rid rid; int seeding; struct refs { Pair{String,Tag}[] tags; Pair{String,Sha}[] refs; } } struct Delegate { Did id; Maybe{String} delegate_alias; } struct Tag { Sha commit; Maybe{Tagger} tagger; Maybe{String} message; } struct Tagger { String name; String email; int timestamp; } struct Node { Nid id; String agent; struct config { String node_alias; struct peers { String type; } String[] connect; String[] externalAddresses; String network; String log; String relay; struct limits { int routingMaxSize; int routingMaxAge; int gossipMaxAge; int fetchConcurrency; int maxOpenFiles; struct rate { struct inbound { double fillRate; int capacity; } struct outbound { double fillRate; int capacity; } } struct connection { int inbound; int outbound; } String fetchPackReceive; } int workers; struct seedingPolicy { String default_policy; } } String state; Maybe{String} description; } struct RepoPolicy { String policy; String scope; } struct NodeMeta { String node_alias; Did did; struct ssh { String full; String hash; } } struct Commit { Sha id; struct author { String name; String email; } String summary; String description; Sha[] parents; struct committer { String name; String email; int time; } } struct CommitFull { Commit commit; Diff diff; Pair{String,File}[] files; String[] branches; } struct File { Oid id; bool binary; String content; } struct Diff { FileDiff[] files; struct stats { int filesChanged; int insertions; int deletions; } } struct FileDiff { String status; Maybe{Path} path; Maybe{Path} oldPath; Maybe{Path} newPath; struct diff { String type; Hunk[] hunks; struct stats { int additions; int deletions; } String eof; } Maybe{FileDesc} old; Maybe{FileDesc} new; } struct FileDesc { Oid oid; String mode; } struct Hunk { String header; Line[] lines; struct old { int start; int end; } struct new { int start; int end; } } struct Line { String type; String line; Maybe{int} lineNo; Maybe{int} lineNoOld; Maybe{int} lineNoNew; } struct DiffFull { Diff diff; Pair{Oid,File}[] files; Commit[] commits; } union Response { Repo[] delegates_repos; Node node; Pair{Rid,RepoPolicy}[] node_policies_repos; RepoPolicy node_policies_repo; NodeMeta nodes; Rid[] nodes_inventory; Repo[] repo_root; SearchRepo[] repo_search; Repo repo; Commit[] history; CommitFull commit; DiffFull diff; ResponseRaw activity; ResponseRaw tree_root; ResponseRaw tree; ResponseRaw stats_tree; ResponseRaw remotes; ResponseRaw remote; ResponseRaw blob; ResponseRaw readme; ResponseRaw job; ResponseRaw issues; ResponseRaw issue; ResponseRaw patches; ResponseRaw patch; ResponseRaw stats; } struct Client { Curl* c; char[10485760]* buffer; String endpoint; usz buffer_index; } alias Did = String; alias Nid = String; alias Rid = String; alias Sha = String; alias Base = Sha; alias Oid = Sha; alias Path = String; alias Peer = String; struct Args { Did did; Nid nid; Rid rid; Sha sha; Base base; Oid oid; Path path; Peer peer; } fn Client* new_client(String endpoint) { Client* self = mem::alloc(Client); self.c = curl::easy_init(); self.endpoint = endpoint; self.buffer = mem::alloc(char[10485760]); curl::easy_setopt(self.c, CurlOption.WRITEFUNCTION, &cb); curl::easy_setopt(self.c, CurlOption.WRITEDATA, self); return self; } fn Response Client.request(Client* self, Request r, String params, Args args) { self.buffer_index = 0; CurlResult cr; String s; @pool() { s = self.endpoint .tconcat(r.path) .treplace("{did}", args.did) .treplace("{nid}", args.nid) .treplace("{rid}", args.rid) .treplace("{sha}", args.sha) .treplace("{base}", args.base) .treplace("{oid}", args.oid) .treplace("{path}", args.path) .treplace("{peer}", args.peer) .tconcat(params); curl::easy_setopt(self.c, CurlOption.URL, s); cr = curl::easy_perform(self.c); }; Object* j = json::tparse(self.raw_response())!!; switch (r) { case Request.DELEGATES_REPOS: return (Response){.delegates_repos = parse_repos(j) }; case Request.NODE: return (Response){.node = parse_node(j) }; case Request.NODE_POLICIES_REPOS: return (Response){.node_policies_repos = parse_repo_policies(j)}; case Request.NODE_POLICIES_REPO: return (Response){.node_policies_repo = parse_repo_policy(j) }; case Request.NODES: return (Response){.nodes = parse_node_meta(j) }; case Request.NODES_INVENTORY: return (Response){.nodes_inventory = parse_rids(j) }; case Request.REPO_ROOT: return (Response){.repo_root = parse_repos(j) }; case Request.REPO_SEARCH: return (Response){.repo_search = parse_search_repos(j) }; case Request.REPO: return (Response){.repo = parse_repo(j) }; case Request.HISTORY: return (Response){.history = parse_commits(j) }; case Request.COMMIT: return (Response){.commit = parse_commit_full(j) }; case Request.DIFF: return (Response){.diff = parse_diff_full(j) }; case Request.ACTIVITY: return (Response){.activity = self.raw_response() }; case Request.TREE_ROOT: return (Response){.tree_root = self.raw_response() }; case Request.TREE: return (Response){.tree = self.raw_response() }; case Request.STATS_TREE: return (Response){.stats_tree = self.raw_response() }; case Request.REMOTES: return (Response){.remotes = self.raw_response() }; case Request.REMOTE: return (Response){.remote = self.raw_response() }; case Request.BLOB: return (Response){.blob = self.raw_response() }; case Request.README: return (Response){.readme = self.raw_response() }; case Request.JOB: return (Response){.job = self.raw_response() }; case Request.ISSUES: return (Response){.issues = self.raw_response() }; case Request.ISSUE: return (Response){.issue = self.raw_response() }; case Request.PATCHES: return (Response){.patches = self.raw_response() }; case Request.PATCH: return (Response){.patch = self.raw_response() }; case Request.STATS: return (Response){.stats = self.raw_response() }; } } fn Line parse_line(Object* j) { Line l; l.type = j.get_string("type")!!; l.line = j.get_string("line")!!; if (j.has_key("lineNo")) { l.lineNo.has_value = true; l.lineNo.value = j.get_int("lineNo")!!; } else { l.lineNo.has_value = false; } if (j.has_key("lineNoOld")) { l.lineNoOld.has_value = true; l.lineNoOld.value = j.get_int("lineNoOld")!!; } else { l.lineNoOld.has_value = false; } if (j.has_key("lineNoNew")) { l.lineNoNew.has_value = true; l.lineNoNew.value = j.get_int("lineNoNew")!!; } else { l.lineNoNew.has_value = false; } return l; } fn Hunk parse_hunk(Object* j) { Hunk h; int i; h.header = j.get_string("header")!!; i = 0; while (i < j.get("lines").array.len()!!) { h.lines = array::tconcat(h.lines, (Line[1]){parse_line(j.get("lines").get_at(i)!!)}); i++; } h.old.start = j.get("old").get_int("start")!!; h.old.end = j.get("old").get_int("end")!!; h.new.start = j.get("new").get_int("start")!!; h.new.end = j.get("new").get_int("end")!!; return h; } fn FileDiff parse_file_diff(Object* j) { FileDiff fd; int i; fd.status = j.get_string("status")!!; if (j.has_key("path")) { fd.path.has_value = true; fd.path.value = j.get_string("path")!!; } else { fd.path.has_value = false; } if (j.has_key("oldPath")) { fd.oldPath.has_value = true; fd.oldPath.value = j.get_string("oldPath")!!; } else { fd.oldPath.has_value = false; } if (j.has_key("newPath")) { fd.newPath.has_value = true; fd.newPath.value = j.get_string("newPath")!!; } else { fd.newPath.has_value = false; } if (j.has_key("diff")) { fd.diff.type = j.get("diff").get_string("type")!!; if (fd.diff.type != "binary") { i = 0; while (i < j.get("diff").get("hunks").array.len()!!) { fd.diff.hunks = array::tconcat(fd.diff.hunks, (Hunk[1]){parse_hunk(j.get("diff").get("hunks").get_at(i)!!)}); i++; } fd.diff.stats.additions = j.get("diff").get("stats").get_int("additions")!!; fd.diff.stats.deletions = j.get("diff").get("stats").get_int("deletions")!!; fd.diff.eof = j.get("diff").get_string("eof")!!; } } if (j.has_key("old")) { fd.old.has_value = true; fd.old.value.oid = j.get("old").get_string("oid")!!; fd.old.value.mode = j.get("old").get_string("mode")!!; } else { fd.old.has_value = false; } if (j.has_key("new")) { fd.new.has_value = true; fd.new.value.oid = j.get("new").get_string("oid")!!; fd.new.value.mode = j.get("new").get_string("mode")!!; } else { fd.new.has_value = false; } return fd; } fn Diff parse_diff(Object* j) { Diff d; int i = 0; while (i < j.get("files").array.len()!!) { d.files = array::tconcat(d.files, (FileDiff[1]){parse_file_diff(j.get("files").get_at(i)!!)}); i++; } d.stats.filesChanged = j.get("stats").get_int("filesChanged")!!; d.stats.insertions = j.get("stats").get_int("insertions")!!; d.stats.deletions = j.get("stats").get_int("deletions")!!; return d; } fn File parse_file(Object* j) { File f; f.id = j.get_string("id")!!; f.binary = j.get_bool("binary")!!; f.content = j.get_string("content")!!; return f; } fn CommitFull parse_commit_full(Object* j) { CommitFull cf; int i; cf.commit = parse_commit(j.get("commit")!!); cf.diff = parse_diff(j.get("diff")!!); Object.@each_entry(j.get("files")!!; String key, Object* value) { cf.files = array::tconcat(cf.files, (Pair{String,File}[1]){{key, parse_file(value)}}); }; i = 0; while (i < j.get("branches").array.len()!!) { cf.branches = array::tconcat(cf.branches, (String[1]){j.get("branches").get_string_at(i)!!}); i++; } return cf; } fn DiffFull parse_diff_full(Object* j) { DiffFull df; int i; df.diff = parse_diff(j.get("diff")!!); Object.@each_entry(j.get("files")!!; String key, Object* value) { df.files = array::tconcat(df.files, (Pair{Oid,File}[1]){{key, parse_file(value)}}); }; i = 0; while (i < j.get("commits").array.len()!!) { df.commits = array::tconcat(df.commits, (Commit[1]){parse_commit(j.get("commits").get_at(i)!!)}); i++; } return df; } fn Commit[] parse_commits(Object* j) { Commit[] cs; int i = 0; while (i < j.array.len()) { cs = array::tconcat(cs, (Commit[1]){parse_commit(j.get_at(i))}); i++; } return cs; } fn Commit parse_commit(Object* j) { Commit c; int i; c.id = j.get_string("id")!!; c.author.name = j.get("author").get_string("name")!!; c.author.email = j.get("author").get_string("email")!!; c.summary = j.get_string("summary")!!; c.description = j.get_string("description")!!; i = 0; while (i < j.get("parents").array.len()!!) { c.parents = array::tconcat(c.parents, (Sha[1]){j.get("parents").get_string_at(i)!!}); i++; } c.committer.name = j.get("committer").get_string("name")!!; c.committer.email = j.get("committer").get_string("email")!!; c.committer.time = j.get("committer").get_int("time")!!; return c; } fn Rid[] parse_rids(Object* j) { Rid[] rs; int i = 0; while (i < j.array.len()) { rs = array::tconcat(rs, (Rid[1]){j.get_string_at(i)!!}); i++; } return rs; } fn NodeMeta parse_node_meta(Object* j) { NodeMeta nm; nm.node_alias = j.get_string("alias")!!; nm.did = j.get_string("did")!!; nm.ssh.full = j.get("ssh").get_string("full")!!; nm.ssh.hash = j.get("ssh").get_string("hash")!!; return nm; } fn Pair{Rid,RepoPolicy}[] parse_repo_policies(Object* j) { Pair{Rid,RepoPolicy}[] rps; int i = 0; while (i < j.array.len()) { rps = array::tconcat(rps, (Pair{Rid,RepoPolicy}[1]){{j.get_at(i).get_string("rid")!!, parse_repo_policy(j.get_at(i).get("policy")!!)}}); i++; } return rps; } fn RepoPolicy parse_repo_policy(Object* j) { RepoPolicy rp; rp.policy = j.get_string("policy")!!; rp.scope = j.get_string("scope")!!; return rp; } fn Node parse_node(Object* j) { Node n; int i; n.id = j.get_string("id")!!; n.agent = j.get_string("agent")!!; n.config.node_alias = j.get("config").get_string("alias")!!; n.config.peers.type = j.get("config").get("peers").get_string("type")!!; i = 0; while (i < j.get("config").get("connect").array.len()!!) { n.config.connect = array::tconcat(n.config.connect, (String[1]){j.get("config").get("connect").get_string_at(i)!!}); i++; } i = 0; while (i < j.get("config").get("externalAddresses").array.len()!!) { n.config.externalAddresses = array::tconcat(n.config.externalAddresses, (String[1]){j.get("config").get("externalAddresses").get_string_at(i)!!}); i++; } n.config.network = j.get("config").get_string("network")!!; n.config.log = j.get("config").get_string("log")!!; n.config.relay = j.get("config").get_string("relay")!!; n.config.limits.routingMaxSize = j.get("config").get("limits").get_int("routingMaxSize")!!; n.config.limits.routingMaxAge = j.get("config").get("limits").get_int("routingMaxAge")!!; n.config.limits.gossipMaxAge = j.get("config").get("limits").get_int("gossipMaxAge")!!; n.config.limits.fetchConcurrency = j.get("config").get("limits").get_int("fetchConcurrency")!!; n.config.limits.maxOpenFiles = j.get("config").get("limits").get_int("maxOpenFiles")!!; n.config.limits.rate.inbound.fillRate = j.get("config").get("limits").get("rate").get("inbound").get_float("fillRate")!!; n.config.limits.rate.inbound.capacity = j.get("config").get("limits").get("rate").get("inbound").get_int("capacity")!!; n.config.limits.rate.outbound.fillRate = j.get("config").get("limits").get("rate").get("outbound").get_float("fillRate")!!; n.config.limits.rate.outbound.capacity = j.get("config").get("limits").get("rate").get("outbound").get_int("capacity")!!; n.config.limits.connection.inbound = j.get("config").get("limits").get("connection").get_int("inbound")!!; n.config.limits.connection.outbound = j.get("config").get("limits").get("connection").get_int("outbound")!!; n.config.workers = j.get("config").get_int("workers")!!; n.config.seedingPolicy.default_policy = j.get("config").get("seedingPolicy").get_string("default")!!; n.state = j.get_string("state")!!; if (j.has_key("description")) { n.description = {j.get_string("description")!!, true}; } else { n.description.has_value = false; } return n; } fn Repo[] parse_repos(Object* j) { Repo[] rs; int i = 0; while (i < j.array.len()) { rs = array::tconcat(rs, (Repo[1]){parse_repo(j.get_at(i))}); i++; } return rs; } fn Repo parse_repo(Object* j) { Repo r; int i; r.payloads.xyz.radicle.project.data.defaultBranch = j.get("payloads").get("xyz.radicle.project").get("data").get_string("defaultBranch")!!; r.payloads.xyz.radicle.project.data.description = j.get("payloads").get("xyz.radicle.project").get("data").get_string("description")!!; r.payloads.xyz.radicle.project.data.name = j.get("payloads").get("xyz.radicle.project").get("data").get_string("name")!!; r.payloads.xyz.radicle.project.meta.head = j.get("payloads").get("xyz.radicle.project").get("meta").get_string("head")!!; r.payloads.xyz.radicle.project.meta.issues.open = j.get("payloads").get("xyz.radicle.project").get("meta").get("issues").get_int("open")!!; r.payloads.xyz.radicle.project.meta.issues.closed = j.get("payloads").get("xyz.radicle.project").get("meta").get("issues").get_int("closed")!!; r.payloads.xyz.radicle.project.meta.patches.open = j.get("payloads").get("xyz.radicle.project").get("meta").get("patches").get_int("open")!!; r.payloads.xyz.radicle.project.meta.patches.draft = j.get("payloads").get("xyz.radicle.project").get("meta").get("patches").get_int("draft")!!; r.payloads.xyz.radicle.project.meta.patches.archived = j.get("payloads").get("xyz.radicle.project").get("meta").get("patches").get_int("archived")!!; r.payloads.xyz.radicle.project.meta.patches.merged = j.get("payloads").get("xyz.radicle.project").get("meta").get("patches").get_int("merged")!!; i = 0; while (i < j.get("delegates").array.len()!!) { r.delegates = array::tconcat(r.delegates, (Delegate[1]){parse_delegate(j.get("delegates").get_at(i)!!)}); i++; } r.threshold = j.get_int("threshold")!!; r.visibility.type = j.get("visibility").get_string("type")!!; r.rid = j.get_string("rid")!!; r.seeding = j.get_int("seeding")!!; i = 0; Object.@each_entry(j.get("refs").get("tags")!!; String key, Object* value) { r.refs.tags = array::tconcat(r.refs.tags, (Pair{String,Tag}[1]){{key, parse_tag(value)}}); }; Object.@each_entry(j.get("refs").get("refs")!!; String key, Object* value) { r.refs.refs = array::tconcat(r.refs.refs, (Pair{String,Sha}[1]){{key, value.s}}); }; return r; } fn SearchRepo[] parse_search_repos(Object* j) { SearchRepo[] srs; int i = 0; while (i < j.array.len()) { srs = array::tconcat(srs, (SearchRepo[1]){parse_search_repo(j.get_at(i))}); i++; } return srs; } fn SearchRepo parse_search_repo(Object* j) { SearchRepo sr; int i; sr.payloads.xyz.radicle.project.defaultBranch = j.get("payloads").get("xyz.radicle.project").get_string("defaultBranch")!!; sr.payloads.xyz.radicle.project.description = j.get("payloads").get("xyz.radicle.project").get_string("description")!!; sr.payloads.xyz.radicle.project.name = j.get("payloads").get("xyz.radicle.project").get_string("name")!!; i = 0; while (i < j.get("delegates").array.len()!!) { sr.delegates = array::tconcat(sr.delegates, (Delegate[1]){parse_delegate(j.get("delegates").get_at(i)!!)}); i++; } sr.rid = j.get_string("rid")!!; sr.seeds = j.get_int("seeds")!!; return sr; } fn Delegate parse_delegate(Object* j) { Delegate d; d.id = j.get_string("id")!!; if (j.has_key("alias")) { d.delegate_alias.has_value = true; d.delegate_alias.value = j.get_string("alias")!!; } else { d.delegate_alias.has_value = false; } return d; } fn Tag parse_tag(Object* j) { io::printn(j); Tag t; t.commit = j.get_string("commit")!!; if (j.has_key("tagger")) { t.tagger.has_value = true; t.tagger.value.name = j.get("tagger").get_string("name") ?? ""; t.tagger.value.email = j.get("tagger").get_string("email") ?? ""; t.tagger.value.timestamp = j.get("tagger").get_int("timestamp") ?? 0; } else { t.tagger.has_value = false; } t.message = {j.get_string("message"), true} ?? {"", false}; return t; } fn ResponseRaw Client.raw_response(Client* self) { return (ResponseRaw)self.buffer.[..self.buffer_index]; } fn usz cb(char* buffer, usz size, usz nitems, Client* client) { client.buffer.[client.buffer_index..client.buffer_index+nitems-1] = buffer[..nitems-1]; client.buffer_index += nitems; return nitems; }