mzLib is a mass-spectrometry and proteomics library written in C#. mzLibRust makes its functionality callable from Rust, with no .NET installation.
It is the sibling of pyMzLib and speaks the same language-neutral bridge: a self-contained executable exchanging a versioned JSON envelope over stdin/stdout, which assumes nothing about the language calling it. Everything genuinely hard already lives there — the mzLib interop, the composition of mzLib's own methods, and the availability-versus-correctness error classification. This crate is the thin, idiomatic Rust surface over it, which is why it exists at all: a second binding costs a transport module and some typed structs, not a second implementation of mzLib.
// PRIDE Archive — find projects by keyword, see what is in one, and pull it down.
let hits = mzlib::pride::search("plasmodium falciparum schizont")?;
println!("{} {:?}", hits[0].accession, hits[0].matched_fields()); // PXD070842 ["references", "title"]
let files = mzlib::pride::list_files("PXD000001")?;
let small: Vec<_> = files.iter()
.filter(|f| f.size_mb() < 5.0 && f.downloadable())
.cloned()
.collect();
mzlib::pride::download_files(&small, "downloads", &Default::default())?;
// Peptidoforms — digest an annotated protein and fragment its peptides.
let digest = mzlib::peptidoform::fragments("P02768")?;
println!("{}", digest.modification_census.explain());
// 14 of 38 annotated modifications were applied, across 14 residue positions.
// Excluded by type: 24 × glycosylation site — mzLib loads only 'modified residue' and …
// A MetaMorpheus full sequence in Unimod accessions, by mzLib's own sequence conversion.
let unimod = mzlib::peptidoform::convert(&["[UniProt:N-acetylserine on S]SEQK"])?;
println!("{:?}", unimod.outputs()?); // [Some("[UNIMOD:1]SEQK")]
// FlashLFQ — label-free quantification across runs.
use mzlib::flashlfq::{quantify_with, QuantifyOptions, SpectraFile};
let result = quantify_with(
"AllPSMs.psmtsv",
&[SpectraFile::from("run_3.mzML"), SpectraFile::from("run_4.mzML")],
&QuantifyOptions { match_between_runs: true, ..Default::default() },
)?;
println!("{} peptides rescued by MBR", result.mbr_rescued_peptide_count());
// Median polish — re-roll proteins under a new design, without re-reading any spectra.
use mzlib::flashlfq::{median_polish_with, DesignEntry, MedianPolishOptions};
let proteins = median_polish_with("QuantifiedPeptides.tsv", &MedianPolishOptions {
design: vec![DesignEntry::new("run_3").condition("control"),
DesignEntry::new("run_4").condition("treated")],
..Default::default()
})?;
// Readers — spectra as well as search output; identify any type mzLib knows and read ALL of them.
// mzML, Thermo .raw, Bruker .d, timsTOF .d, MGF and msalign all read through read_spectra.
let scans = mzlib::readers::read_spectra("run.mzML")?;
println!("{} scans", scans.scan_count);
let info = mzlib::readers::identify("psm.tsv")?;
println!("{} {:?}", info.file_type, info.views); // MsFraggerPsm ["quantifiable"]
let table = mzlib::readers::read_records("toppic_prsm.tsv")?; // works on every format
let e_values = table.columns.floats("e_value")?; // Vec<Option<f64>>
// Hundreds of runs, one bridge process, one long table; each run's instrument in its report.
let runs = mzlib::readers::read_spectra_many(&paths, &Default::default())?;
println!("{:?}", runs.files[0].source.as_ref().map(|s| &s.instrument_serial_number));
// MetaMorpheus protein groups and FlashLFQ peptides as long tables: one row per sample.
let groups = mzlib::readers::read_protein_groups("AllQuantifiedProteinGroups.tsv")?;
// SDRF experimental design — what was searched, pooled across experiments with provenance.
let design = mzlib::sdrf::pool_labelled(&[("PXD000070.sdrf.tsv", "malaria"),
("PXD026824.sdrf.tsv", "colon")])?;
println!("{:?}", design.document.value("characteristics[organism part]"));
// Is it well-formed, do the files agree, does it describe its samples at all? mzLib's answers.
let findings = mzlib::sdrf::validate("PXD000070.sdrf.tsv")?;
let verdicts = mzlib::sdrf::assess_many(&corpus, &Default::default())?; // Informative / Partial / Skeleton
let ages = mzlib::sdrf::parse_ages(&["58Y", "40Y-85Y", ">=90Y", "63"])?; // years, or why not
// The label-free design FlashLFQ needs, read out of the SDRF — or every reason it cannot be.
let design = mzlib::sdrf::design_with("PXD067622.sdrf.tsv", &mzlib::sdrf::DesignOptions {
condition_columns: vec!["factor value[genotype]".into(), "factor value[treatment]".into()],
..Default::default()
})?;
let spectra = design.spectra()?; // Vec<SpectraFile>, 0-based, for flashlfq::quantify_with
// Isobaric kits — every TMT, TMTpro, iTRAQ and DiLeu channel with its reporter-ion m/z, from mzLib.
let tmtpro = mzlib::isobaric::kits(Some("TMT18"))?;
// Protein databases — what an accession is, which gene it is, whether a peptide is unique.
let db = mzlib::proteins::read(&["human.xml"])?;
println!("{:?}", db.taxonomy()?.get("P04406")); // Some(Some("9606"))
let calls = mzlib::proteins::classify_peptides(&["YLYEIAR"], &["human.xml", "bovine.fasta"])?;
// Gene Ontology for each MetaMorpheus protein group, every member kept, against a go.obo you pin.
let go = mzlib::proteins::annotate_go("AllQuantifiedProteinGroups.tsv", "human.xml", "go.obo")?;
// Differential abundance with no R — limma's moderated t, held by mzLib to limma itself.
let fit = mzlib::stats::fit("log2_intensities.tsv", "design.tsv", &["treated"])?;
let q = mzlib::stats::adjust(&[Some(0.0002), None, Some(0.031)])?; // None: not in the familyWhat differs between mzLib's formats is not whether you can read them but what the columns
mean. The counts are the pinned bridge's; mzlib::readers::formats() gives the live ones.
| function | reads | columns |
|---|---|---|
read_records |
all of them | that format's own fields, under mzLib's names |
read_results |
4 | uniform quantifiable view |
read_features |
2 | uniform ms1_features view |
read_matches |
6 | uniform spectral_match view |
read_spectra |
7 | scan headers; peaks opt-in |
About half of them belong to no cross-format family at all — TopPIC, Crux, MSFragger's
peptide and protein tables, the FlashDeconv formats, SDRF, and the MetaMorpheus and FlashLFQ
quantification tables with their RNA counterparts. mzLib parses them into a format-specific shape and there is no uniform view to project them
onto, so read_records is what reaches them; it is a necessity, not a convenience.
Many files are one call, not a loop. Every reader has a _many twin that takes a list and
returns one long table whose first two columns name the file each row came from, plus one report
per file (its instrument, its absent fields, why it failed under OnError::Skip). The list is read
by one bridge process, threads files at a time; the answer is identical at any thread count.
The quantification tables have functions of their own: MetaMorpheus protein groups
(read_protein_groups), FlashLFQ peptides (read_quantified_peptides) and PTM site occupancy
(read_occupancy), each as a long table, one row per record per sample. An RNA search's transcript
groups and oligos read through the same three, and so does a protein-group table MetaMorpheus wrote
without quantifying (its intensity is in absent_fields). read_records
reads those files too, but their per-sample values are dictionaries it names and cannot project.
SDRF is the exception: read it with the sdrf module, not read_records. read_records joins
each SDRF row into one semicolon-separated string, and SDRF's own NT=…;AC=… grammar puts semicolons
inside cells, so the string cannot be split back apart. mzlib::sdrf::read and mzlib::sdrf::pool
return every cell intact, in a row-major shape that keeps SDRF's repeated column names. The same
module asks mzLib's three questions about a document — validate (is it well-formed?),
lint_labelled (do several files write the same thing the same way?) and assess (does it
describe its samples at all?) — which are blind in different places, which is why there are three.
samples lifts each sample's characteristics, and parse_ages reads characteristics[age] into
years, refusing any cell that would need a guess. design_with reads the label-free experimental
design out of an SDRF in MetaMorpheus's terms, 0-based and ready for FlashLFQ, or refuses and lists
every reason at once; out writes MetaMorpheus's ExperimentalDesign.tsv.
Full sequences convert in the peptidoform module: convert_with hands MetaMorpheus or mzLib
full sequences to mzLib's SequenceConversionService and returns one row per input with mzLib's
verdict (converted, converted_with_warnings or failed), the modifications it could not write,
and its warnings. Unimod is the default target. mzLib's ProForma target does not yet resolve
UniProt modifications and writes them back by name, so convert to Unimod for those. It needs the
bridge from pyMzLib 0.4.0, the one this crate pins; an older bridge is refused before anything is
spawned.
Isobaric kits have their own module, isobaric: kits lists every kit mzLib can name, each
channel's label and theoretical reporter-ion m/z, and the window mzLib reads a reporter intensity
in. Nothing in the table is typed in on this side.
Statistics have their own module, stats: fit runs limma's lmFit and
eBayes(legacy = TRUE) on a feature-by-sample table and a design, one moderated t per feature and
coefficient with Benjamini-Hochberg; adjust adjusts p-values from anywhere else; meta pools
effect sizes across studies with DerSimonian-Laird. mzLib holds each to the R reference (limma,
metafor) to 1e-8, and the crate ships that reference so the check can be re-run.
Protein databases have their own module, proteins: read gives one row per protein —
organism, NCBI taxon, genes, mass — with GO terms and Ensembl gene links on request;
resolve_genes_with resolves proteins to stable Ensembl gene ids against a gene set you pin;
classify_peptides sorts peptides into Unique, SharedWithinGene, SharedAcrossGenes or
NotInDatabase, treating I and L as the same residue. A FASTA's silence about GO and Ensembl is
reported in absent_fields, never as an empty answer. annotate_go_with annotates a stored
MetaMorpheus protein-group table with GO terms: one row per (group, term) that any member holds,
directly or through an ancestor, naming the members that carry it, so consensus and direct-only
views are filters rather than a pick. It reads only a go.obo you keep; update_go is the one call
that fetches a release.
Because the column set depends on the format, a read returns a Table rather than a struct with
named fields — with typed accessors that project a wire null onto Option, so a missing cell can
never silently become a zero and can never shorten a column:
let t = mzlib::readers::read_records("crux.txt")?;
for (sequence, score) in t.columns.strings("base_sequence")?
.iter()
.zip(t.columns.floats("x_corr_score")?)
{
if let (Some(sequence), Some(score)) = (sequence, score) { println!("{sequence}\t{score}"); }
}Names follow mzLib. A field here means exactly what it means in the mzLib source, the
MetaMorpheus output columns, and the papers — match_between_runs, ppm_tolerance,
protein_groups, detection_type. Nothing is renamed to look more Rust-like, because renaming
forces every reader to hold a translation table in their head, and makes "what is ppm_tolerance?"
answerable straight from FlashLFQ's own docs.
The types disclose the traps. Where mzLib applies an invisible rule, this crate surfaces it rather than swallowing it. The clearest case is quantification:
struct Peptide { intensities: HashMap<String, f64>, /* 0.0 = not measured here */ }
struct ProteinGroup { intensities: HashMap<String, Option<f64>>, /* None = could not be resolved */ }FlashLFQ's median-polish protein quant emits NaN when a protein's peptide matrix is degenerate. In
Python that distinction lives in the documentation and has to be remembered. Here the compiler makes
you handle it — and a 0.0 peptide can never be mistaken for an unresolvable one.
The same doctrine runs through the rest: [Digest::truncated] tells you the silent isoform cap
bound, [ModificationCensus::explain] tells you what UniProt annotated versus what could be used,
and FlashLfqResults::peaks is documented as the surface to read for match-between-runs because the
peptide roll-up drops most transfers.
You do not need one to build, test, document or lint the crate. cargo test runs the whole
offline suite with no network and no .NET on the machine. A bridge is required only for calls that
actually reach mzLib, and a missing one is a runtime error with instructions, never a build failure
— so contributors are never blocked by a 130 MB payload they may not want.
The simplest way to get one:
let bridge = mzlib::install::install_bridge()?;It downloads the bridge pyMzLib published for your platform, verifies it against a checksum recorded
in this crate, unpacks it and caches it per-user. It asks first, and nothing calls it for you —
fetching the bridge is a user action, never a side effect of building. A built-in default download
would make a first cargo check pull 130 MB and break vendored and air-gapped builds, so build.rs
has no default URL and never will. mzLibR's mzlibr_install_bridge() is the same function for the
same reasons.
Once it is installed the crate finds it with nothing set. Full resolution order:
MZLIB_BRIDGE— a path to a bridge you already have. Always wins, checked at runtime too._dotnet/<runtime-identifier>/mzlib-bridge[.exe]beside the crate, staged bybuild.rsor byscripts/stage-bridge.ps1.- The per-user cache, where
install_bridge()puts one.
The version it fetches is pinned, not "whichever is newest" — a crate that silently followed the
newest bridge could not be reproducible, and would change what it runs without anybody merging
anything. .github/workflows/bridge-watch.yml checks weekly and opens a pull request when pyMzLib
publishes a newer one, regenerating the pinned digests from that release's SHA256SUMS.
build.rs will also download at build time from MZLIB_BRIDGE_URL (verified against
MZLIB_BRIDGE_SHA256) if you set it. That takes a URL for a bare executable — it unpacks
nothing, so it is not how to consume the published mzlib-bridge-<rid>.tar.gz, whose payload is a
whole tree. Point it at an archive and the build script says so and stages nothing.
The quickest route, if you have a pyMzLib checkout — it already stages a bridge for its wheel:
.\scripts\stage-bridge.ps1 -PyMzLibRoot ..\pyMzLib # copy the one pyMzLib staged
.\scripts\stage-bridge.ps1 -PyMzLibRoot ..\pyMzLib -Build # or build a fresh one (needs .NET)The script probes the staged binary by asking it for its version, because a payload that cannot report that will certainly fail when the crate calls it.
Or simply:
export MZLIB_BRIDGE=/path/to/mzlib-bridge
cargo test --features liveMZLIB_BRIDGE is also a licence affordance, not only a convenience: it is how you point this
crate at a bridge built from a modified mzLib, exercising your LGPL §4 right to relink without
rebuilding anything here. See NOTICE.
This section used to end by promising that "download-at-build becomes the default path once
pyMzLib's CI publishes the raw bridge binaries as release assets". Those assets exist as of
v0.1.0.dev4, and the promise was not kept — deliberately. Downloading by default is the one thing
this crate will not do, so the fetch became install_bridge() instead.
The API reference is rustdoc (cargo doc --open). Two things about it are deliberate:
- Every example runs. The examples are doctests, executed in CI against a stand-in bridge that answers each call from a fixture recorded from the real one — the same recordings pyMzLib's and mzLibR's examples replay — and only when the recording fits the call. The few that cannot run download from EBI, and say so.
- The facts come from one spec per wire verb. Parameters with their units, result fields with their units and what a null means, error kinds, caveats, the mzLib code wrapped, and the same verb's spelling in Python and R are rendered from language-neutral specs all three bindings share, and a test fails when a documented field drifts from its spec. See docs/reference-facts.md.
Changes are recorded in CHANGELOG.md.
cargo test # the offline suite, the spec lint and every doc example: no network, no bridge
MZLIB_BRIDGE=… cargo test --features live # the live canaries; they SKIP on an outageThe offline suite is the default because it must pass anywhere, in milliseconds. Live canaries are the ones that would catch mzLib, PRIDE or UniProt changing under us, and they skip rather than fail when a service is down — an ambiguous red build gets ignored, which is how a genuine contract break goes unnoticed for a month.
See docs/test-parity.md for the test-by-test mapping against pyMzLib, and docs/findings.md for defects this port surfaced upstream.
Same as mzLib. See LICENSE.