Rust API¶
nexus-raw-core is the protocol without a UI: the operations of the CLI as a small async API around one facade.
The crate is organized in layers, the CLI is a thin shell over the top layer, and every layer below it is public API: you can embed just the transport, just the transfer engine, or the whole facade.
The exhaustive type-level documentation is cargo doc -p nexus-raw-core --open.
Layers¶
| Layer | Modules | Content | Depends on |
|---|---|---|---|
| L0 · transport | transport |
one client: retries, backoff, stall detection, TLS, auth, Range-resume | nothing above |
| L0 · primitive | primitive |
get/put/head/sha: single-object, no verification |
transport |
| L1 · transfer | sync |
directory up/down, the symmetric diff, sha-sibling markers, parallel workers | transport, primitive |
| L2 · layout | layout |
channels (token files with any name), search-based listings | transport |
| facade | nxr |
Nxr, the single entry point that threads everything together |
all of the above |
| L3 · UX | the CLI crate | doctor, hints, human and NDJSON rendering, exit codes | the facade |
The rule is one-directional: layers never import upward. The facade and the layering, as the crate itself draws it:
%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#eef2f2", "primaryTextColor": "#243b3a", "primaryBorderColor": "#5f7470", "lineColor": "#5f7470", "fontFamily": "inherit"}}}%%
flowchart BT
T["L0 transport<br/>retries · TLS · auth · stall"]
P["L0 primitive<br/>get · put · head · sha"]
S["L1 sync<br/>up · down · diff · scan"]
L["L2 layout<br/>channels · listings"]
F["Nxr facade"]
C["L3 CLI<br/>flags · rendering · exit codes"]
P --> T
S --> P
L --> T
F --> S
F --> L
F --> P
C --> F
Add the dependency¶
The facade¶
Nxr is the single entry point.
It is built from a Config (one invocation's settings, no config file) and an event channel it emits progress into.
use std::time::Duration;
use nexus_raw_core::{Config, Nxr, Event};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
// Drain events while the operation runs (or after, from the buffered queue).
let renderer = tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
println!("{}", ev.to_json());
}
});
let nxr = Nxr::new(
Config {
base: "https://nexus.example.com/repository/raw-main/1.4.0/".into(),
tls_insecure: false,
workers: 8,
retry_attempts: 4,
connect_timeout: Duration::from_secs(15),
stall_timeout: Duration::from_secs(30),
auth: Some("Basic Y2ktYm90OnRva2Vu".into()), // resolved from -u or env by callers
},
tx,
)?;
Config::base is the directory URL this invocation works on, normalized to a trailing /.
auth is the ready Authorization header value.
Build it from -u user:pass, NXR_AUTH (base64 user:pass) or NXR_USERNAME + NXR_PASSWORD, exactly as the CLI does.
creds::resolve does this for you.
Config::validate enforces the ranges the CLI flags map onto: workers in 1..=64, positive timeouts.
A complete, self-contained round trip (publish a directory against a scratch server and fetch it back) is cargo run -p nexus-raw-core --example publish.
It starts its own mock server, so the example runs with zero setup, and its shape (build config → open channel → up → down → drop facade → join the event printer) is the intended embedding pattern.
Operations¶
Every method mirrors a CLI command one-to-one.
| Method | Returns | Mirrors | Contract |
|---|---|---|---|
get(url, out, cont) |
GetOutcome |
nxr get |
stream a URL to a file or stdout, resume through <out>.part |
put(url, src, sha) |
(u64, Option<Digest>) |
nxr put |
PUT a file, optionally with its sha-sibling |
head(url) |
HeadInfo |
nxr head |
status and metadata: 404 is a normal result, not an error |
sha(src) |
Digest |
nxr sha |
stream a ShaSource::File or ShaSource::Url through sha256 |
scan(dir) |
Vec<ArtifactName> |
the up input set |
the plain-mode local listing up starts from |
diff(dir, names, mode, markers) |
Vec<Action> |
up --plan |
the symmetric plan without transferring |
delta(dir, enum_src) |
Vec<Delta> |
nxr diff |
the local-against-storage report: Same, MissingLocal, MissingRemote, Diverged (size and/or sha), nothing written |
up(dir, names, gen_markers, claim) |
Summary |
nxr up |
verified upload: bytes, then the marker of the same name |
up_if_changed(dir, names, gen_markers, claim) |
Summary |
nxr up --if-changed |
the same upload with the skip rule: only a verified remote digest skips, divergence re-PUTs |
delta_search(dir, prefixes) |
Vec<Delta> |
nxr diff --via search --ls |
the delta report built from the search index in O(pages) requests, a digestless asset never reads Same |
get_segmented(url, out, segments) |
GetOutcome |
nxr get --segments N |
N concurrent byte-range spans assembled positionally, verified and renamed atomically, degraded to the single stream when Range is ignored |
down(dir, enum_src, fresh) |
Summary |
nxr down |
verified download: the enumeration source is mandatory |
down_with_segments(dir, enum_src, fresh, segments) |
Summary |
nxr down --segments N |
the same download with each artifact split into N concurrent Range spans |
clone_plan(dir, prefixes, state, verify) |
Vec<CloneAction> |
nxr clone --plan |
the repository-wide clone plan, nothing written |
clone_to(dir, prefixes, state, verify) |
Summary |
nxr clone |
verified repository clone with the optional local fact cache |
mirror_plan(dst, enum_src) |
Vec<MirrorAction> |
mirror --dry-run |
probe both sides and classify, emit the plan, move nothing |
mirror(dst, enum_src) |
Summary |
nxr mirror |
pour the enumerated names into a second facade's base: staged through the down machinery, written in up's order |
rm(enum_src) |
Summary |
nxr rm |
DELETE the enumerated names, marker before bytes: 404 is success, divergence is never checked |
rm_plan(enum_src) |
Vec<RmAction> |
rm without --yes |
Remove/Missing per name by remote existence, nothing deleted |
enumerate(enum_src) |
Vec<ArtifactName> |
- | resolve any enumeration source to its deduplicated names |
service_repos() |
Vec<RepoInfo> |
nxr service repos |
server metadata through the service REST API, not storage protocol |
point_clear(url) |
ClearOutcome |
nxr point |
DELETE the pointer: Cleared or Absent |
verify(dir, names) |
Summary |
nxr verify |
offline bytes+marker+digest check, emits only the summary |
channel_get(url) |
Option<String> |
nxr channel get |
None on 404 |
channel_set(url, token, if_forward) |
ChannelOutcome |
nxr channel set |
Written { from } or Skipped { current } |
ls_entries() |
Vec<Entry> |
nxr ls |
the raw tree, any depth: folders first then files, markers hidden |
ls_assets() |
Vec<ArtifactName> |
nxr ls --assets |
search-API traversal, experimental |
One parameter deserves its one-liner:
names: Option<Vec<ArtifactName>>restricts a transfer to an explicit name list.Nonescans the directory.
Failures collect per name into Summary.failed (legacy strings) and Summary.failures (the same names with their exit-code class) while the rest of the transfer completes.
Failure is { name: String, code: u8 }, where code is the exit-code class: 1 data, 2 misuse, 3 transport.
Refusals (Verdict) abort before any byte moves and convert into Error (see errors).
L0: transport and primitives¶
The client owns every cross-cutting concern so that no other layer repeats them: retry policy with backoff and jitter, connect timeout, stall abort, TLS mode, the Authorization header, and the Range-resume download.
use std::time::Duration;
use nexus_raw_core::{Config, Event};
use nexus_raw_core::transport::client::NexusClient;
let cfg = Config {
base: "https://nexus.example.com/repository/raw-main/".into(),
tls_insecure: false,
workers: 8,
retry_attempts: 4,
connect_timeout: Duration::from_secs(15),
stall_timeout: Duration::from_secs(30),
auth: Some("Basic Y2ktYm90OnRva2Vu".into()),
};
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
let client = NexusClient::new(&cfg, nexus_raw_core::Progress::new(tx))?;
// `client` is shared behind an Arc by the facade. Every request it makes
// retries, stalls and authenticates by exactly the policy configured above.
The primitive layer is four functions over that client.
sha of a local file never touches the network:
use nexus_raw_core::{Digest, Nxr, ShaSource};
async fn local_digest(nxr: &Nxr, path: &str) -> Result<Digest, nexus_raw_core::Error> {
// No request is made for a File source.
nxr.sha(ShaSource::File(path.into())).await
}
GetOutcome carries size, the digest of the written file (stdout mode skips hashing and returns None), and resumed_from, the part offset the transfer continued from (0 for a fresh download).
L1: transfer¶
sync classifies, then executes.
scan and local_statuses are pure filesystem work, classify is a pure function from local and remote states to a plan, and up/down execute a plan with the worker pool.
use nexus_raw_core::Nxr;
fn what_would_up_scan(nxr: &Nxr) -> Result<(), nexus_raw_core::Error> {
// Pure local listing: the names `up` would consider, grammar-checked.
let names = nxr.scan("dist/1.4.0".as_ref())?;
println!("would consider {} names", names.len());
Ok(())
}
An Action is one name's line of a plan and has exactly three shapes: Upload { name, size, digest }, Download { name, size, digest } and Skip { name, digest }.
Mode::Up and Mode::Down select which side wins a single-completed-copy name, per the symmetric diff matrix.
The digest inside an Upload action is present only when markers are enabled: knowing the digest and writing the marker are separate concerns.
Every directory transfer runs under an adaptive worker budget: it starts at the configured maximum, halves in-flight capacity on every Retry-After failure (a 429), and recovers one worker per success streak, never above the maximum.
The budget lives on the facade for the duration of a run.
last_run_limit() reports the trajectory (current limit, the lowest it reached) for tests and dashboards.
The wire behavior is unchanged: a polite client on a throttling server, byte-for-byte the same protocol.
L2: layout¶
layout reads and writes the conventional objects: channel refs and search-based listings.
channel_set returns ChannelOutcome::Written { from } or ChannelOutcome::Skipped { current }, where the forward-only guard compares tokens in dotted-numeric order.
Enumeration¶
down refuses to guess what to fetch, so the facade takes an explicit source.
The CLI and the Node wrapper resolve a file URL into that one name before they call the facade.
The variants mirror the CLI's enumeration flags:
| Variant | CLI flag | Semantics |
|---|---|---|
Enumeration::Names(Vec<ArtifactName>) |
--name (repeatable) |
exact: every name grammar-checked at parse time |
Enumeration::Search |
--ls |
best-effort: walks the server search API with continuation tokens |
Enumeration::Filtered { inner, prefixes } |
--prefix |
the inner source, narrowed to whole-segment prefixes after resolution |
An enumeration that produces zero names refuses with Enumerate, because an empty plan is treated as a wrong URL rather than as success.
Events¶
Event is the progress stream, one channel the facade emits into.
The NDJSON shapes are fixed by golden tests, and Event::to_json() is the single serializer, so an embedding UI and the CLI output cannot diverge.
| Variant | Fires | NDJSON shape |
|---|---|---|
Plan { upload, download, skip } |
once per transfer, before execution | {"event":"plan","upload":["a.zip"],"download":[],"skip":[]} |
ArtifactStarted { name, dir, total } |
when a name begins moving | {"event":"artifact","name":"a.zip","state":"uploading","done":0,"total":38} |
ArtifactBytes { name, dir, done, total } |
coalesced: at most one per 200 ms per name | {"event":"artifact","name":"a.zip","state":"downloading","done":12,"total":38} |
ArtifactDone { name, dir, skipped, done, total } |
when a name settles | {"event":"artifact","name":"a.zip","state":"done","done":38,"total":38} |
Retrying { name, attempt, reason } |
before each replayed attempt | {"event":"retrying","name":"a.zip","attempt":2,"reason":"transport: …"} |
Removing { name } |
before a name's DELETEs go out | {"event":"removing","name":"a.zip"} |
Removed { name } |
when at least one of the name's objects was deleted | {"event":"removed","name":"a.zip"} |
Missing { name } |
when every DELETE of the name answered 404 | {"event":"missing","name":"a.zip"} |
Summary(Summary) |
last event of every transfer | {"event":"summary","kind":"transfer","uploaded":1,"downloaded":0,"skipped":2,"sealed":0,"removed":0,"failed":[],"failures":[]} |
dir renders as the state prefix: Dir::Up → "uploading", Dir::Down → "downloading".
ArtifactDone reuses the same artifact event with the final state: "done", or "skipped" when the diff found nothing to move.
total is null when the server advertises no Content-Length.
A real stream, captured by down --name app-1.4.0.zip --json against the mock:
{"download":["app-1.4.0.zip"],"event":"plan","skip":[],"upload":[]}
{"done":0,"event":"artifact","name":"app-1.4.0.zip","state":"downloading","total":45}
{"done":45,"event":"artifact","name":"app-1.4.0.zip","state":"downloading","total":45}
{"done":45,"event":"artifact","name":"app-1.4.0.zip","state":"done","total":45}
{"downloaded":1,"event":"summary","kind":"transfer","failed":[],"failures":[],"sealed":0,"skipped":0,"uploaded":0}
head and channel set print their final object instead of a stream: head → {"content_type":…,"size":…,"status":…,"url":…}, channel set → {"from":…,"outcome":"written","token":…,"url":…}.
get -o and put print a transfer's event stream and end with their final object: get → {"bytes":…,"ok":true,"out":…,"resumed_from":…,"sha256":…,"url":…}, put → {"bytes":…,"marker":…,"ok":true,"url":…}.
The CLI output section shows where each shape appears.
Errors¶
nexus_raw_core::Error is the whole taxonomy: Mismatch, Incomplete, UnsafeName, Missing, Enumerate, Auth, Transport, Http, ServiceMissing, SearchRepoMissing, ReadOnly, Misuse, Io, SmallObjectCap, ServerBody.
Error::exit_code() maps it to the CLI's exit classes and Error::hint() returns the human hint.
Both are covered variant by variant in errors and exit codes.
Verdict (diff refusals: Mismatch, Missing) converts into Error with From, so a refused plan and a refused transfer look identical to a caller.
Node bindings¶
The npm package @nexus-raw/nxr ships a subset of the Nxr facade to Node as promises: the primitives (get, put, head, sha), the directory transfers (up, down, clone, clonePlan, rm, mirror), the delta report (diff), verify, the channel and pointer operations, the search listings (lsEntries, lsAssets) and serviceRepos.
Every command is one self-sufficient call: the URL in argv, credentials in the auth option or the environment, no config file.
down mirrors the CLI's file-URL shorthand: with no names and no ls, a URL that ends at a file downloads that one artifact, with its parent directory as the base.
Each promise resolves to the command's result or rejects with an Error carrying exitCode and hint.
An optional onEvent callback receives the JSON-parsed Event objects, and a transfer promise resolves to the final summary.
import { up, down } from '@nexus-raw/nxr'
const summary = await up('dist/1.4.0', 'https://nexus.example.com/repository/raw-main/1.4.0/', {
claimFirst: 'version.json',
onEvent: (event) => console.log(event),
})
The crate is built on napi-rs 3: async exports run on its built-in tokio runtime, the build scripts come from the @napi-rs/cli, and the prebuilt addons are wired for x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin and aarch64-apple-darwin.
The typed declarations live in crates/nexus-raw-napi/index.d.ts, and crates/nexus-raw-napi/smoke.mjs is the runnable offline check of the built addon.
Guarantees¶
- No protocol logic outside the crate: the CLI is flags and rendering only.
- Credentials never appear in
Events, error messages orDisplayimpls. - Markers, the write order, the classification and resume follow the wire protocol exactly, and any divergence is a bug.