1use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::table::{TableAlign, TableData, convert_table_pages};
12
13const MAX_BED_BYTES: u64 = 128 * 1024 * 1024;
14const MAX_BED_LINES: usize = 2_000_000;
15const MAX_BED_LINE_BYTES: usize = 1 << 20;
16const MAX_BED_FEATURES: usize = 100_000;
17const MAX_BED_COLUMNS: usize = 12;
18const MAX_BED_CELLS: usize = 1_200_000;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub(crate) enum BedFormat {
22 Bed,
23 BedGraph,
24}
25
26pub(crate) fn looks_like_bedgraph_prefix(prefix: &[u8]) -> bool {
27 let Ok(text) = std::str::from_utf8(prefix) else {
28 return false;
29 };
30 let lower = text.to_ascii_lowercase();
31 if lower
32 .lines()
33 .any(|line| line.trim_start().starts_with("track") && line.contains("type=bedgraph"))
34 {
35 return true;
36 }
37 first_interval_line(text)
38 .is_some_and(|fields| fields.len() == 4 && fields[3].parse::<f64>().is_ok())
39}
40
41pub(crate) fn looks_like_bed_prefix(prefix: &[u8]) -> bool {
42 let Ok(text) = std::str::from_utf8(prefix) else {
43 return false;
44 };
45 first_interval_line(text).is_some_and(|fields| {
46 fields.len() >= 3
47 && fields.len() <= 12
48 && fields[1].parse::<u64>().is_ok()
49 && fields[2].parse::<u64>().is_ok()
50 })
51}
52
53fn first_interval_line(text: &str) -> Option<Vec<&str>> {
54 text.lines().map(str::trim).find_map(|line| {
55 if line.is_empty()
56 || line.starts_with('#')
57 || line.starts_with("track")
58 || line.starts_with("browser")
59 {
60 return None;
61 }
62 let fields = line.split_whitespace().collect::<Vec<_>>();
63 if fields.len() >= 3
64 && fields.first().is_some_and(|field| {
65 field.starts_with("chr")
66 || field.starts_with("scaffold")
67 || field.starts_with("contig")
68 || field.starts_with("NC_")
69 })
70 {
71 Some(fields)
72 } else {
73 None
74 }
75 })
76}
77
78pub(crate) fn convert(
79 path: &Path,
80 options: &ConvertOptions,
81 sink: &mut dyn PageConsumer,
82 format: BedFormat,
83) -> Result<Vec<String>> {
84 let bytes = read_limited_file(
85 path,
86 options.max_input_bytes.min(MAX_BED_BYTES),
87 "BED input",
88 )?;
89 let text = String::from_utf8(bytes)
90 .map_err(|error| Error::InvalidInput(format!("BED input must be UTF-8/ASCII: {error}")))?;
91 let (mut table, warnings) = parse(&text, format)?;
92 let format_name = match format {
93 BedFormat::Bed => "bed",
94 BedFormat::BedGraph => "bedgraph",
95 };
96 let mut page_sink = BedPageSink {
97 inner: sink,
98 format_name,
99 warnings: &warnings,
100 };
101 convert_table_pages(&mut table, format_name, options, &mut page_sink)?;
102 Ok(warnings)
103}
104
105struct BedPageSink<'a> {
106 inner: &'a mut dyn PageConsumer,
107 format_name: &'static str,
108 warnings: &'a [String],
109}
110impl PageConsumer for BedPageSink<'_> {
111 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
112 page.source_format = self.format_name.into();
113 page.title = format!(
114 "{} interval annotations",
115 self.format_name.to_ascii_uppercase()
116 );
117 page.description =
118 "Genome intervals and values are displayed inertly; no reference data is loaded".into();
119 for warning in self.warnings {
120 page.warn(warning.clone());
121 }
122 self.inner.consume(page)
123 }
124}
125
126fn parse(text: &str, format: BedFormat) -> Result<(TableData, Vec<String>)> {
127 let lines = text.lines().collect::<Vec<_>>();
128 if lines.len() > MAX_BED_LINES {
129 return Err(Error::LimitExceeded(format!(
130 "BED input exceeds {MAX_BED_LINES} lines"
131 )));
132 }
133 let mut rows = Vec::new();
134 let mut warnings = Vec::new();
135 let mut directives = false;
136 let mut cells = 0usize;
137 for (line_number, original) in lines.iter().enumerate() {
138 if original.len() > MAX_BED_LINE_BYTES {
139 return Err(Error::LimitExceeded(format!(
140 "BED line {} exceeds {MAX_BED_LINE_BYTES} bytes",
141 line_number + 1
142 )));
143 }
144 let line = original.trim();
145 if line.is_empty() || line.starts_with('#') {
146 continue;
147 }
148 if line.starts_with("track") || line.starts_with("browser") {
149 directives = true;
150 continue;
151 }
152 let fields = line.split_whitespace().collect::<Vec<_>>();
153 match format {
154 BedFormat::BedGraph => {
155 if fields.len() != 4 {
156 return Err(Error::InvalidInput(format!(
157 "bedGraph line {} must contain four columns",
158 line_number + 1
159 )));
160 }
161 let (start, end) =
162 validate_interval(fields[0], fields[1], fields[2], line_number + 1)?;
163 let value = fields[3].parse::<f64>().map_err(|_| {
164 Error::InvalidInput(format!(
165 "bedGraph line {} has invalid dataValue",
166 line_number + 1
167 ))
168 })?;
169 if !value.is_finite() {
170 return Err(Error::InvalidInput(
171 "bedGraph dataValue is non-finite".into(),
172 ));
173 }
174 let _ = (start, end);
175 rows.push(fields.into_iter().map(str::to_owned).collect::<Vec<_>>());
176 }
177 BedFormat::Bed => {
178 if fields.len() < 3 || fields.len() > MAX_BED_COLUMNS {
179 return Err(Error::InvalidInput(format!(
180 "BED line {} must contain 3–12 columns",
181 line_number + 1
182 )));
183 }
184 let (start, end) =
185 validate_interval(fields[0], fields[1], fields[2], line_number + 1)?;
186 if let Some(score) = fields.get(4)
187 && *score != "."
188 {
189 let value = score.parse::<u16>().map_err(|_| {
190 Error::InvalidInput(format!(
191 "BED line {} has invalid score",
192 line_number + 1
193 ))
194 })?;
195 if value > 1000 {
196 return Err(Error::InvalidInput(format!(
197 "BED line {} score exceeds 1000",
198 line_number + 1
199 )));
200 }
201 }
202 if let Some(strand) = fields.get(5)
203 && !matches!(*strand, "+" | "-" | "." | "?")
204 {
205 return Err(Error::InvalidInput(format!(
206 "BED line {} has invalid strand",
207 line_number + 1
208 )));
209 }
210 for index in [6usize, 7] {
211 if let Some(value) = fields.get(index)
212 && *value != "."
213 {
214 let _ = parse_u64(value, "BED thick coordinate", line_number + 1)?;
215 }
216 }
217 if let Some(rgb) = fields.get(8)
218 && *rgb != "."
219 {
220 validate_rgb(rgb, line_number + 1)?;
221 }
222 if fields.len() >= 12 {
223 let count = parse_u64(fields[9], "BED blockCount", line_number + 1)? as usize;
224 let sizes = parse_list(fields[10], "BED blockSizes", line_number + 1)?;
225 let starts = parse_list(fields[11], "BED blockStarts", line_number + 1)?;
226 if count == 0 || count != sizes.len() || count != starts.len() {
227 return Err(Error::InvalidInput(format!(
228 "BED line {} blockCount does not match block lists",
229 line_number + 1
230 )));
231 }
232 if starts.first().copied() != Some(0) {
233 return Err(Error::InvalidInput(format!(
234 "BED line {} first blockStart must be 0",
235 line_number + 1
236 )));
237 }
238 let interval_span =
239 usize::try_from(end.saturating_sub(start)).map_err(|_| {
240 Error::LimitExceeded(format!(
241 "BED line {} interval exceeds address space",
242 line_number + 1
243 ))
244 })?;
245 for (block_start, block_size) in starts.iter().zip(&sizes) {
246 let block_end = block_start.checked_add(*block_size).ok_or_else(|| {
247 Error::LimitExceeded("BED block coordinate overflowed".into())
248 })?;
249 if block_end > interval_span {
250 return Err(Error::InvalidInput(format!(
251 "BED line {} block exceeds interval",
252 line_number + 1
253 )));
254 }
255 if *block_size == 0 {
256 return Err(Error::InvalidInput(format!(
257 "BED line {} block has zero size",
258 line_number + 1
259 )));
260 }
261 }
262 }
263 rows.push(fields.into_iter().map(str::to_owned).collect::<Vec<_>>());
264 }
265 }
266 if rows.len() > MAX_BED_FEATURES {
267 return Err(Error::LimitExceeded(format!(
268 "BED input exceeds {MAX_BED_FEATURES} features"
269 )));
270 }
271 cells = cells
272 .checked_add(rows.last().map(Vec::len).unwrap_or(0))
273 .ok_or_else(|| Error::LimitExceeded("BED cell count overflowed".into()))?;
274 if cells > MAX_BED_CELLS {
275 return Err(Error::LimitExceeded(format!(
276 "BED input exceeds {MAX_BED_CELLS} cells"
277 )));
278 }
279 }
280 if rows.is_empty() {
281 return Err(Error::InvalidInput(
282 "BED input contains no interval rows".into(),
283 ));
284 }
285 if directives {
286 warnings.push("BED track/browser directives were ignored".into());
287 }
288 let headers = match format {
289 BedFormat::BedGraph => vec!["chrom", "chromStart", "chromEnd", "dataValue"],
290 BedFormat::Bed => vec![
291 "chrom",
292 "chromStart",
293 "chromEnd",
294 "name",
295 "score",
296 "strand",
297 "thickStart",
298 "thickEnd",
299 "itemRgb",
300 "blockCount",
301 "blockSizes",
302 "blockStarts",
303 ],
304 };
305 let alignments = (0..headers.len())
306 .map(|index| {
307 if matches!(index, 1 | 2 | 4 | 6 | 7 | 9) {
308 TableAlign::Right
309 } else {
310 TableAlign::Left
311 }
312 })
313 .collect();
314 Ok((
315 TableData {
316 headers: headers.into_iter().map(str::to_owned).collect(),
317 rows,
318 alignments,
319 raw_source: String::new(),
320 },
321 warnings,
322 ))
323}
324
325fn validate_interval(chrom: &str, start: &str, end: &str, line: usize) -> Result<(u64, u64)> {
326 if chrom.is_empty() || chrom.contains('\t') {
327 return Err(Error::InvalidInput(format!(
328 "BED line {line} chromosome is empty"
329 )));
330 }
331 let start = start
332 .parse::<u64>()
333 .map_err(|_| Error::InvalidInput(format!("BED line {line} chromStart is invalid")))?;
334 let end = end
335 .parse::<u64>()
336 .map_err(|_| Error::InvalidInput(format!("BED line {line} chromEnd is invalid")))?;
337 if end < start {
338 return Err(Error::InvalidInput(format!(
339 "BED line {line} chromEnd precedes chromStart"
340 )));
341 }
342 Ok((start, end))
343}
344fn parse_u64(value: &str, context: &str, line: usize) -> Result<u64> {
345 value
346 .parse::<u64>()
347 .map_err(|_| Error::InvalidInput(format!("{context} on line {line} is invalid")))
348}
349fn parse_list(value: &str, context: &str, line: usize) -> Result<Vec<usize>> {
350 value
351 .trim_end_matches(',')
352 .split(',')
353 .filter(|part| !part.is_empty())
354 .map(|part| {
355 parse_u64(part, context, line).and_then(|number| {
356 usize::try_from(number).map_err(|_| {
357 Error::LimitExceeded(format!("{context} on line {line} exceeds address space"))
358 })
359 })
360 })
361 .collect()
362}
363fn validate_rgb(value: &str, line: usize) -> Result<()> {
364 let fields = value.split(',').collect::<Vec<_>>();
365 if fields.len() != 3 {
366 return Err(Error::InvalidInput(format!(
367 "BED line {line} itemRgb is invalid"
368 )));
369 }
370 for field in fields {
371 if field.parse::<u16>().ok().is_none_or(|number| number > 255) {
372 return Err(Error::InvalidInput(format!(
373 "BED line {line} itemRgb is outside 0..255"
374 )));
375 }
376 }
377 Ok(())
378}