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())?;
// ... and check what landed against the MD5 and exact size PRIDE recorded for each file.
let checksums = mzlib::pride::file_checksums("PXD000001")?;
println!("{}", checksums["README.txt"].md5); // 492753137013825025e6729c8798664f
// 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);
// Spectral libraries (.msl): every precursor, or every fragment ion, with "not available" as None.
let library = mzlib::library::read("predicted.msl")?;
println!("{:?}", library.columns.strings("retention_time_unit")?); // iRT or minutes, per row
// ... and a run's minutes on the library's iRT scale, from anchors you choose, and back.
use mzlib::library::{calibrate_rt, RtAnchor, RtQuery};
let calibration = calibrate_rt(&[RtAnchor::new("run_3", 28.71, 33.98) /* , ... */],
&[RtQuery::irt_to_minutes("run_3", 11.89)], &Default::default())?;
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
// A TMT design (1-based, as the SDRF writes it), after cutting the SDRF to the files a search read.
let scoped = mzlib::sdrf::restrict("PXD008841.sdrf.tsv", &["X-calib.mzML".into()])?;
let tmt = mzlib::sdrf::isobaric_design_with("PXD008841_searched.sdrf.tsv",
&mzlib::sdrf::IsobaricDesignOptions { kit: Some("TMT10".into()),
plex_column: Some("comment[plex]".into()), ..Default::default() })?;
// 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"])?;
// Where is canonical residue 82 in the isoform? And what entry, isoform and variants is this?
let aln = mzlib::proteins::align(&canonical, &isoform)?;
println!("{:?}", aln.map_source_to_target(82)?); // Some(76), or None at a gap
let parsed = mzlib::proteins::parse_accessions(&["P04637-2", "NP_000537.3_R72P"])?;
// Orthologs across species, exactly as Ensembl Compara states them, from every species' dump.
let snap = mzlib::proteins::build_orthology_with("116", &orthology_options)?;
// Human p53's Ser15 in mouse, through Compara's gene-tree alignment: Ser18, or why there is none.
let site = mzlib::proteins::map_residues_with(&[("ENSP00000269305", 15, "ENSMUSP00000104298")],
&map_residues_options)?;
// 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 family
// The design and its contrasts from a sample sheet, then every contrast with a 95% interval.
let design = mzlib::diff::design_with("samples.tsv", &mzlib::diff::DesignOptions {
factors: vec![mzlib::diff::FactorSpec::new("condition", Some("control"))],
out: Some("design".into()),
..Default::default()
})?;
let stratum = &design.strata[0];
let pairs = mzlib::stats::fit_with("log2_intensities.tsv", stratum.design_file.as_deref().unwrap(),
&["condition=treated"], &mzlib::stats::FitOptions {
contrasts: stratum.contrasts_file.as_deref().map(Into::into),
..Default::default()
})?;
// Every peptide in every sample, log2 or None with the reason; MBR values in or out, your call.
let runs = [mzlib::diff::ObservationRun::new("run1", "ctrl_1"), mzlib::diff::ObservationRun::new("run2", "drug_1")];
let table = mzlib::diff::observations("QuantifiedPeptides.tsv", &runs, mzlib::diff::QuantBasis::MsmsOnly)?;
// A mixed model on logit occupancy, when some runs only counted k of n peptidoforms.
let latent = mzlib::stats::fit_latent_mixed_with("design.tsv", "animals.tsv", &["trait"],
mzlib::stats::Link::Identity, &mzlib::stats::FitLatentMixedOptions {
responses: Some("logit_occupancy.tsv".into()), counts: Some("counts.tsv".into()),
..Default::default()
})?;
// PTM site occupancy per run, a floor never read as 0; then pairs of sites across runs.
let occ = mzlib::ptmqtl::occupancy("observations.tsv")?;
let pairs = mzlib::ptmqtl::pairs("observations.tsv", mzlib::ptmqtl::PairType::Covarying)?;What 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. fit_with also
tests contrasts with confidence intervals, and takes mzLib's marginal-likelihood prior;
combine_p combines p-values across studies by Fisher or Stouffer, signed by each effect;
correlate_with is Spearman against a per-sample trait, partial on request; fit_logistic_with
and fit_mixed_with fit a logistic regression (a detection table) and a random-intercept mixed
model per feature; fit_latent_mixed_with fits that mixed model on a latent scale (logit
occupancy) from samples measured on it, counted through it (k of n), or both, through a link that
fit_link_with fits; and entrapment_fdp_with checks a search's FDR claim from its entrapment
hits. Each is held to its R reference (glm, nlme, glmer, glmmTMB, pchisq, cor.test,
ppcor) in the live tests.
The design and the observations have their own module, diff: design_with turns a sample
sheet and a model into mzLib's design matrix per stratum, which equals R's model.matrix, says
which comparisons these samples cannot answer and why, and writes the design and contrasts files
stats::fit_with reads; observations_with turns a FlashLFQ or MetaMorpheus peptide table into
every peptide in every sample, as log2 intensity or None with the reason, and measures the
shared-peptide normalization shifts.
PTM site occupancy has its own module, ptmqtl: occupancy_with computes every site's
occupancy per run with its censoring state, pairs_with finds modifications on one molecule or
co-varying across runs (counting one-form runs as tied bounds, or given each run's depth, on
request), and trait_effects_with fits the effect of a per-run trait on each site's log odds of
occupancy, natural or log2, with a random intercept per biological replicate.
Spectral libraries, library: read gives one row per precursor (or per fragment ion), and
calibrate_rt fits each run's map from minutes onto the library's iRT scale from anchors you
choose, with mzLib's robust LOWESS or line, converting the times you send either way.
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. map_residues_with carries a residue to another species through Ensembl
Compara's gene-tree alignment, saying GapInTarget rather than offering the nearest residue.
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 65 MB download 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 65 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.