///|
pub fn scan_tsv_source(source : String) -> TsvScanStats {
  let mut total = 0
  let mut blank = 0
  let mut comments = 0
  let mut data = 0
  let raw_lines = source.split("\n").to_array()
  for index, raw_line in raw_lines {
    if index == raw_lines.length() - 1 && raw_line.is_empty() {
      continue
    }
    total += 1
    let line = raw_line.trim(chars=" \r\t")
    if line.is_empty() {
      blank += 1
    } else if line.get_char(0) == Some('#') {
      comments += 1
    } else {
      data += 1
    }
  }
  {
    total_lines: total,
    blank_lines: blank,
    comment_lines: comments,
    data_lines: data,
  }
}

///|
pub fn render_qrels_tsv(qrels : Array[JudgedDoc]) -> String {
  let rows : Array[String] = []
  let sorted = deduplicate_qrels(qrels)
  for item in sorted {
    rows.push("\{item.query_id}\t\{item.doc_id}\t\{item.relevance}")
  }
  rows.join("\n")
}

///|
pub fn render_run_tsv(run : Array[RetrievedDoc]) -> String {
  let rows : Array[String] = []
  let sorted = deduplicate_run(run)
  for item in sorted {
    rows.push("\{item.query_id}\t\{item.doc_id}\t\{item.score}")
  }
  rows.join("\n")
}

///|
pub fn render_pools_tsv(pools : Array[CandidatePool]) -> String {
  let rows : Array[String] = []
  let sorted = merge_candidate_pools(pools, [])
  for pool in sorted {
    for doc_id in pool.doc_ids {
      rows.push("\{pool.query_id}\t\{doc_id}")
    }
  }
  rows.join("\n")
}

///|
pub fn parse_qrels_tsv_strict(
  source : String,
) -> Result[Array[JudgedDoc], String] {
  let parsed = match parse_qrels_tsv(source) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let summary = validate_dataset(parsed, [])
  if summary.error_count > 0 {
    Err(render_validation_report(summary))
  } else {
    Ok(deduplicate_qrels(parsed))
  }
}

///|
pub fn parse_run_tsv_strict(
  source : String,
) -> Result[Array[RetrievedDoc], String] {
  let parsed = match parse_run_tsv(source) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  for item in parsed {
    if item.score.is_nan() || item.score.is_inf() {
      return Err("run contains a non-finite score".to_string())
    }
  }
  Ok(deduplicate_run(parsed))
}