//! Loose spec accessors over `serde_json::Value` — no schema re-validation (the Python side //! already loaded/validated the spec). Mirrors the handful of `seqcolyte.spec.model.Spec` //! accessors the checks/eval need: R1 `cycles`, the cell-barcode segment slice, the TSO oligo, //! and `platform_params.dark_base`. use anyhow::{anyhow, Result}; use serde_json::Value; const TSO_OLIGO: &str = "oligo_template_switching_oligo_tso"; pub struct SpecInfo { pub r1_cycles: i64, pub cb_start: usize, pub cb_len: usize, pub tso: Option, pub dark_base: Option, } pub fn parse_spec(v: &Value) -> Result { let reads = v .get("read_structure") .and_then(|r| r.get("reads")) .and_then(|r| r.as_array()) .ok_or_else(|| anyhow!("spec missing read_structure.reads"))?; let r1 = reads .iter() .find(|rd| rd.get("read").and_then(|x| x.as_str()) == Some("R1")) .ok_or_else(|| anyhow!("spec has no R1 read"))?; // `spec.read("R1").get("cycles", 28)` let r1_cycles = r1.get("cycles").and_then(|c| c.as_i64()).unwrap_or(28); // `spec.segment_slice("R1", "cell_barcode")` via segment_offsets. let (cb_start, cb_len) = cell_barcode_slice(r1)?; // `spec.oligo_sequence("oligo_template_switching_oligo_tso")` (absent -> None; checks skip). let tso = v .get("oligos") .and_then(|o| o.as_array()) .and_then(|arr| { arr.iter() .find(|ol| ol.get("oligo_id").and_then(|x| x.as_str()) == Some(TSO_OLIGO)) }) .and_then(|ol| ol.get("sequence")) .and_then(|s| s.as_str()) .map(|s| s.to_string()); // `spec.platform_params.get("dark_base")` — falsey (missing / null / empty) -> None. let dark_base = v .get("platform_params") .and_then(|p| p.get("dark_base")) .and_then(|d| d.as_str()) .filter(|s| !s.is_empty()) .and_then(|s| s.bytes().next()); Ok(SpecInfo { r1_cycles, cb_start, cb_len, tso, dark_base, }) } /// Port of `segment_offsets("R1")["cell_barcode"]`: sort R1 segments by `order`, accumulate /// fixed `length`, stop at the first segment without a fixed length, return cell_barcode's /// `(start, len)`. fn cell_barcode_slice(r1: &Value) -> Result<(usize, usize)> { let segs = r1 .get("segments") .and_then(|s| s.as_array()) .ok_or_else(|| anyhow!("R1 has no segments"))?; let mut sorted: Vec<&Value> = segs.iter().collect(); sorted.sort_by_key(|s| s.get("order").and_then(|o| o.as_i64()).unwrap_or(0)); let mut pos: usize = 0; let mut cb: Option<(usize, usize)> = None; for s in sorted { let length = match s.get("length").and_then(|l| l.as_i64()) { Some(l) => l as usize, None => break, // variable-length segment: stop accumulating definite offsets }; let name = s.get("name").and_then(|n| n.as_str()).unwrap_or(""); if name == "cell_barcode" { cb = Some((pos, length)); } pos += length; } cb.ok_or_else(|| anyhow!("R1 has no fixed-length cell_barcode segment")) }